From 8067ff3cf4c49501247a228d9811a5441fac02a0 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Fri, 26 Dec 2025 14:29:19 +0100 Subject: [PATCH] Add Pin Input Components (Phase H2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin.razor: clickable pin with Standing/Fallen/Disabled states - PinPanel.razor: 9-pin layout in classic bowling configuration - NumberPanel.razor: quick-entry 0-9, bell toggle, throw confirm - ThrowPanel.razor: gutter buttons, throw counters, mode display - GameInputPanel.razor: orchestrates all panels, handles throw logic - ThrowResult.cs: throw result record with strike/circle detection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Components/Game/GameInputPanel.razor | 304 ++++++++++++++++++ .../Components/Game/NumberPanel.razor | 178 ++++++++++ src/Koogle.Web/Components/Game/Pin.razor | 113 +++++++ src/Koogle.Web/Components/Game/PinPanel.razor | 101 ++++++ .../Components/Game/ThrowPanel.razor | 165 ++++++++++ src/Koogle.Web/Components/Game/ThrowResult.cs | 44 +++ 6 files changed, 905 insertions(+) create mode 100644 src/Koogle.Web/Components/Game/GameInputPanel.razor create mode 100644 src/Koogle.Web/Components/Game/NumberPanel.razor create mode 100644 src/Koogle.Web/Components/Game/Pin.razor create mode 100644 src/Koogle.Web/Components/Game/PinPanel.razor create mode 100644 src/Koogle.Web/Components/Game/ThrowPanel.razor create mode 100644 src/Koogle.Web/Components/Game/ThrowResult.cs diff --git a/src/Koogle.Web/Components/Game/GameInputPanel.razor b/src/Koogle.Web/Components/Game/GameInputPanel.razor new file mode 100644 index 0000000..91822f6 --- /dev/null +++ b/src/Koogle.Web/Components/Game/GameInputPanel.razor @@ -0,0 +1,304 @@ +@using Koogle.Domain.Enums +@using Koogle.Web.Store.GameState +@using Koogle.Web.Components.Game +@inherits FluxorComponent + +@inject IState GameState +@inject IDispatcher Dispatcher + +
+ @* Current player display *@ +
+ Aktueller Spieler + + @(CurrentPlayerName ?? "Kein Spieler") + + @if (GameState.Value.Participants.Mode == ParticipantsMode.FreeToChoose) + { + + Spieler wechseln + + } +
+ + @* Pin input area *@ +
+ +
+ + @* Number quick-entry *@ +
+ +
+ + @* Throw info and controls *@ +
+ +
+ + @* Undo button *@ + @if (GameState.Value.UndoStack.Count > 0) + { +
+ + Letzten Wurf rückgängig (@GameState.Value.UndoStack.Count) + +
+ } + + @* Error display *@ + @if (!string.IsNullOrEmpty(GameState.Value.Error)) + { + + @GameState.Value.Error + + } + + @* Saving indicator *@ + @if (GameState.Value.IsSaving) + { +
+ + Speichern... +
+ } +
+ + + +@code { + /// + /// Name of the current player (resolved from PersonId). + /// + [Parameter] + public string? CurrentPlayerName { get; set; } + + /// + /// Lookup function to get player names by ID. + /// + [Parameter] + public Func? PlayerNameResolver { get; set; } + + /// + /// Callback to show player selector dialog. + /// + [Parameter] + public EventCallback OnShowPlayerSelector { get; set; } + + /// + /// Callback when a throw is completed. + /// + [Parameter] + public EventCallback OnThrowCompleted { get; set; } + + private int? _selectedNumber; + private bool _hasModifiedPins; + + private bool IsInteractive => GameState.Value.IsGameActive && !GameState.Value.IsLoading; + + private bool CanConfirmThrow => _hasModifiedPins || _selectedNumber.HasValue; + + private void HandlePinClick(int pinNumber) + { + var currentStatus = GetPinStatus(pinNumber); + var newStatus = currentStatus == PinStatus.Standing ? PinStatus.Fallen : PinStatus.Standing; + + Dispatcher.Dispatch(new SetPinStatusAction(pinNumber, newStatus)); + _hasModifiedPins = true; + _selectedNumber = null; + } + + private void HandleNumberClick(int number) + { + // Quick entry: set pins 1-N as fallen, rest as standing + var newState = GameState.Value.ThrowPanel.ResetPins(); + + for (int i = 1; i <= 9; i++) + { + var status = i <= number ? PinStatus.Fallen : PinStatus.Standing; + newState = newState.SetPin(i, status); + } + + // Dispatch individual pin updates to match the new state + for (int i = 1; i <= 9; i++) + { + var targetStatus = i <= number ? PinStatus.Fallen : PinStatus.Standing; + Dispatcher.Dispatch(new SetPinStatusAction(i, targetStatus)); + } + + _selectedNumber = number; + _hasModifiedPins = true; + } + + private void HandleBellClick() + { + Dispatcher.Dispatch(new SetBellValueAction(!GameState.Value.ThrowPanel.BellValue)); + } + + private async Task HandleGutterClick(bool isLeft) + { + // Gutter = 0 pins fallen, auto-confirm throw + Dispatcher.Dispatch(new ResetPinsAction()); + _selectedNumber = 0; + + await ConfirmThrow(isGutter: true, isLeftGutter: isLeft); + } + + private async Task HandleThrowConfirmed() + { + await ConfirmThrow(isGutter: false, isLeftGutter: false); + } + + private async Task ConfirmThrow(bool isGutter, bool isLeftGutter) + { + var fallenPins = GameState.Value.ThrowPanel.CountFallenPins(); + var bellValue = GameState.Value.ThrowPanel.BellValue; + + // Create throw result + var result = new ThrowResult + { + FallenPins = fallenPins, + IsGutter = isGutter, + IsLeftGutter = isLeftGutter, + BellValue = bellValue, + PinStates = GameState.Value.ThrowPanel.GetPins() + }; + + // Calculate new throw panel state + var newThrowPanel = GameState.Value.ThrowPanel with + { + ThrowCounterPerRound = GameState.Value.ThrowPanel.ThrowCounterPerRound + 1, + TotalThrowCounter = GameState.Value.ThrowPanel.TotalThrowCounter + 1, + BellValue = false // Reset bell after throw + }; + + // Check if round is complete + if (newThrowPanel.ThrowCounterPerRound >= newThrowPanel.ThrowsPerRound) + { + newThrowPanel = newThrowPanel with { ThrowCounterPerRound = 0 }; + + // Reset pins if in Reposition mode + if (newThrowPanel.ThrowMode == ThrowMode.Reposition) + { + newThrowPanel = newThrowPanel.ResetPins(); + } + } + else if (newThrowPanel.ThrowMode == ThrowMode.Reposition) + { + // In Reposition mode, always reset pins after each throw + newThrowPanel = newThrowPanel.ResetPins(); + } + + // Dispatch record throw action + Dispatcher.Dispatch(new RecordThrowAction(newThrowPanel, GameState.Value.GameModel)); + + // Reset local state + _selectedNumber = null; + _hasModifiedPins = false; + + // Notify parent + await OnThrowCompleted.InvokeAsync(result); + } + + private void HandleUndo() + { + if (GameState.Value.UndoStack.Count > 0) + { + var lastSnapshot = GameState.Value.UndoStack[^1]; + Dispatcher.Dispatch(new UndoThrowSuccessAction( + lastSnapshot.ThrowPanel, + lastSnapshot.Participants, + lastSnapshot.GameModel)); + } + } + + private async Task ShowPlayerSelector() + { + await OnShowPlayerSelector.InvokeAsync(); + } + + private PinStatus GetPinStatus(int pinNumber) => pinNumber switch + { + 1 => GameState.Value.ThrowPanel.Pin1, + 2 => GameState.Value.ThrowPanel.Pin2, + 3 => GameState.Value.ThrowPanel.Pin3, + 4 => GameState.Value.ThrowPanel.Pin4, + 5 => GameState.Value.ThrowPanel.Pin5, + 6 => GameState.Value.ThrowPanel.Pin6, + 7 => GameState.Value.ThrowPanel.Pin7, + 8 => GameState.Value.ThrowPanel.Pin8, + 9 => GameState.Value.ThrowPanel.Pin9, + _ => PinStatus.Standing + }; +} diff --git a/src/Koogle.Web/Components/Game/NumberPanel.razor b/src/Koogle.Web/Components/Game/NumberPanel.razor new file mode 100644 index 0000000..e543375 --- /dev/null +++ b/src/Koogle.Web/Components/Game/NumberPanel.razor @@ -0,0 +1,178 @@ +@using Koogle.Domain.Enums + +
+
+ @for (int i = 0; i <= 9; i++) + { + var number = i; + + @number + + } +
+ +
+ + + Glocke + + + + + Wurf + +
+
+ + + +@code { + /// + /// Whether the panel is interactive. + /// + [Parameter] + public bool IsInteractive { get; set; } = true; + + /// + /// Whether the throw can be confirmed (pins have been selected). + /// + [Parameter] + public bool CanConfirmThrow { get; set; } = true; + + /// + /// Currently selected number (for highlighting). + /// + [Parameter] + public int? SelectedNumber { get; set; } + + /// + /// Current bell value state. + /// + [Parameter] + public bool BellValue { get; set; } + + /// + /// Callback when a number is clicked (sets all pins 1-N as fallen). + /// + [Parameter] + public EventCallback OnNumberClicked { get; set; } + + /// + /// Callback when bell button is clicked. + /// + [Parameter] + public EventCallback OnBellClicked { get; set; } + + /// + /// Callback when throw is confirmed. + /// + [Parameter] + public EventCallback OnThrowConfirmed { get; set; } + + private Color GetButtonColor(int number) + { + if (SelectedNumber == number) + return Color.Secondary; + + // Special colors for key numbers + return number switch + { + 0 => Color.Error, // Rinne (gutter) + 9 => Color.Success, // Alle (strike) + _ => Color.Primary + }; + } + + private async Task HandleNumberClick(int number) + { + if (!IsInteractive) + return; + + await OnNumberClicked.InvokeAsync(number); + } + + private async Task HandleBellClick() + { + if (!IsInteractive) + return; + + await OnBellClicked.InvokeAsync(); + } + + private async Task HandleConfirmThrow() + { + if (!IsInteractive || !CanConfirmThrow) + return; + + await OnThrowConfirmed.InvokeAsync(); + } +} diff --git a/src/Koogle.Web/Components/Game/Pin.razor b/src/Koogle.Web/Components/Game/Pin.razor new file mode 100644 index 0000000..dd7ab67 --- /dev/null +++ b/src/Koogle.Web/Components/Game/Pin.razor @@ -0,0 +1,113 @@ +@using Koogle.Domain.Enums + +
+ @PinNumber +
+ + + +@code { + /// + /// The pin number (1-9). + /// + [Parameter] + public int PinNumber { get; set; } + + /// + /// Current status of the pin. + /// + [Parameter] + public PinStatus Status { get; set; } = PinStatus.Standing; + + /// + /// Whether the pin is interactive. + /// + [Parameter] + public bool IsInteractive { get; set; } = true; + + /// + /// Callback when pin is clicked. + /// + [Parameter] + public EventCallback OnPinClicked { get; set; } + + private string GetPinClass() => Status switch + { + PinStatus.Standing => "pin-standing", + PinStatus.Fallen => "pin-fallen", + PinStatus.Disabled => "pin-disabled", + _ => "pin-standing" + }; + + private string GetStyle() + { + if (!IsInteractive || Status == PinStatus.Disabled) + { + return "pointer-events: none;"; + } + return string.Empty; + } + + private async Task OnClick() + { + if (!IsInteractive || Status == PinStatus.Disabled) + return; + + await OnPinClicked.InvokeAsync(PinNumber); + } +} diff --git a/src/Koogle.Web/Components/Game/PinPanel.razor b/src/Koogle.Web/Components/Game/PinPanel.razor new file mode 100644 index 0000000..8d2e509 --- /dev/null +++ b/src/Koogle.Web/Components/Game/PinPanel.razor @@ -0,0 +1,101 @@ +@using Koogle.Domain.Enums +@using Koogle.Web.Store.GameState + +
+ @* Row 1: Pin 1 (top) *@ +
+ +
+ + @* Row 2: Pins 2, 3 *@ +
+ + +
+ + @* Row 3: Pins 4, 5, 6 *@ +
+ + + +
+ + @* Row 4: Pins 7, 8 *@ +
+ + +
+ + @* Row 5: Pin 9 (bottom) *@ +
+ +
+
+ + + +@code { + /// + /// Current state of the throw panel containing pin statuses. + /// + [Parameter] + public ThrowPanelState ThrowPanelState { get; set; } = ThrowPanelState.Initial; + + /// + /// Whether the pins are interactive (clickable). + /// + [Parameter] + public bool IsInteractive { get; set; } = true; + + /// + /// Callback when a pin is clicked. + /// + [Parameter] + public EventCallback OnPinClicked { get; set; } + + private PinStatus GetPinStatus(int pinNumber) => pinNumber switch + { + 1 => ThrowPanelState.Pin1, + 2 => ThrowPanelState.Pin2, + 3 => ThrowPanelState.Pin3, + 4 => ThrowPanelState.Pin4, + 5 => ThrowPanelState.Pin5, + 6 => ThrowPanelState.Pin6, + 7 => ThrowPanelState.Pin7, + 8 => ThrowPanelState.Pin8, + 9 => ThrowPanelState.Pin9, + _ => PinStatus.Standing + }; + + private async Task HandlePinClick(int pinNumber) + { + await OnPinClicked.InvokeAsync(pinNumber); + } +} diff --git a/src/Koogle.Web/Components/Game/ThrowPanel.razor b/src/Koogle.Web/Components/Game/ThrowPanel.razor new file mode 100644 index 0000000..60ff48a --- /dev/null +++ b/src/Koogle.Web/Components/Game/ThrowPanel.razor @@ -0,0 +1,165 @@ +@using Koogle.Domain.Enums +@using Koogle.Web.Store.GameState + +
+ @* Gutter buttons *@ +
+ + + Rinne Links + + + + Rinne Rechts + + +
+ + @* Throw counter and mode info *@ +
+ + Wurf @(ThrowPanelState.ThrowCounterPerRound + 1) / @ThrowPanelState.ThrowsPerRound + + + @GetThrowModeDisplay() + + + Gesamt: @ThrowPanelState.TotalThrowCounter + +
+ + @* Bell indicator *@ + @if (ThrowPanelState.BellValue) + { +
+ + Glocke aktiv! +
+ } + + @* Fallen pins display *@ +
+ + @ThrowPanelState.CountFallenPins() + + Kegel gefallen +
+
+ + + +@code { + /// + /// Current state of the throw panel. + /// + [Parameter] + public ThrowPanelState ThrowPanelState { get; set; } = ThrowPanelState.Initial; + + /// + /// Whether the panel is interactive. + /// + [Parameter] + public bool IsInteractive { get; set; } = true; + + /// + /// Callback when gutter (Rinne) is selected. + /// + [Parameter] + public EventCallback OnGutterClicked { get; set; } + + private string GetThrowModeDisplay() => ThrowPanelState.ThrowMode switch + { + ThrowMode.Reposition => "In die Vollen", + ThrowMode.Decrease => "Abräumen", + _ => "Unbekannt" + }; + + private async Task HandleGutterClick(bool isLeft) + { + if (!IsInteractive) + return; + + await OnGutterClicked.InvokeAsync(isLeft); + } +} diff --git a/src/Koogle.Web/Components/Game/ThrowResult.cs b/src/Koogle.Web/Components/Game/ThrowResult.cs new file mode 100644 index 0000000..30eb3cf --- /dev/null +++ b/src/Koogle.Web/Components/Game/ThrowResult.cs @@ -0,0 +1,44 @@ +using Koogle.Domain.Enums; + +namespace Koogle.Web.Components.Game; + +/// +/// Represents the result of a throw. +/// +public record ThrowResult +{ + /// + /// Number of pins knocked down. + /// + public int FallenPins { get; init; } + + /// + /// Whether the throw went into the gutter. + /// + public bool IsGutter { get; init; } + + /// + /// Whether it was the left gutter (if IsGutter is true). + /// + public bool IsLeftGutter { get; init; } + + /// + /// Whether the bell was active for this throw. + /// + public bool BellValue { get; init; } + + /// + /// Individual pin states after the throw. + /// + public PinStatus[] PinStates { get; init; } = []; + + /// + /// Checks if this is a strike (all 9 pins). + /// + public bool IsStrike => FallenPins == 9; + + /// + /// Checks if this is a circle/wreath (8 outer pins, center standing). + /// + public bool IsCircle => FallenPins == 8 && PinStates.Length == 9 && PinStates[4] == PinStatus.Standing; +}