added GameAction Framework
This commit is contained in:
parent
82c6c2d91d
commit
22fbb79801
|
|
@ -0,0 +1,106 @@
|
|||
namespace Koogle.Application.Games;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a game-specific action available in the UI.
|
||||
/// </summary>
|
||||
public record GameActionDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this action (e.g., "pass", "double_down").
|
||||
/// </summary>
|
||||
public required string ActionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display label for the button/menu item.
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional icon (MudBlazor icon name, e.g., "Icons.Material.Filled.SkipNext").
|
||||
/// </summary>
|
||||
public string? Icon { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Color hint for the button (maps to MudBlazor Color: Primary, Secondary, Warning, Error, Success, Info).
|
||||
/// </summary>
|
||||
public string Color { get; init; } = "Primary";
|
||||
|
||||
/// <summary>
|
||||
/// Whether action is enabled based on current game state.
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional tooltip or helper text.
|
||||
/// </summary>
|
||||
public string? Tooltip { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display order for sorting (lower = first).
|
||||
/// </summary>
|
||||
public int Order { get; init; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing a game action.
|
||||
/// </summary>
|
||||
public record GameActionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether action was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated game model after action.
|
||||
/// </summary>
|
||||
public object? UpdatedModel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to rotate to next player.
|
||||
/// </summary>
|
||||
public bool ShouldRotatePlayer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether game has ended.
|
||||
/// </summary>
|
||||
public bool IsGameOver { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Winner ID if game is over.
|
||||
/// </summary>
|
||||
public Guid? WinnerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if action failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trigger events (e.g., penalties).
|
||||
/// </summary>
|
||||
public IReadOnlyList<TriggerEvent> Triggers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failure result with error message.
|
||||
/// </summary>
|
||||
public static GameActionResult Failure(string error) => new() { Success = false, Error = error };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a success result with updated model and rotation info.
|
||||
/// </summary>
|
||||
public static GameActionResult SuccessResult(
|
||||
object updatedModel,
|
||||
bool shouldRotate = false,
|
||||
bool isGameOver = false,
|
||||
Guid? winnerId = null,
|
||||
IReadOnlyList<TriggerEvent>? triggers = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
UpdatedModel = updatedModel,
|
||||
ShouldRotatePlayer = shouldRotate,
|
||||
IsGameOver = isGameOver,
|
||||
WinnerId = winnerId,
|
||||
Triggers = triggers ?? []
|
||||
};
|
||||
}
|
||||
|
|
@ -42,6 +42,28 @@ public interface IGameLogicService
|
|||
/// <param name="setupOptions">Setup options to validate.</param>
|
||||
/// <returns>Validation result with errors if any.</returns>
|
||||
GameSetupValidationResult ValidateSetup(object? setupOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Gets available game-specific actions for the current state.
|
||||
/// </summary>
|
||||
/// <param name="gameModel">Current game model.</param>
|
||||
/// <param name="currentPlayerId">ID of the current player.</param>
|
||||
/// <returns>List of available actions. Empty list if no special actions.</returns>
|
||||
IReadOnlyList<GameActionDescriptor> GetAvailableActions(object gameModel, Guid currentPlayerId);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a game-specific action.
|
||||
/// </summary>
|
||||
/// <param name="gameModel">Current game model.</param>
|
||||
/// <param name="actionId">Action ID to execute.</param>
|
||||
/// <param name="currentPlayerId">ID of the current player.</param>
|
||||
/// <param name="parameters">Optional action parameters.</param>
|
||||
/// <returns>Result of the action execution.</returns>
|
||||
GameActionResult ExecuteAction(
|
||||
object gameModel,
|
||||
string actionId,
|
||||
Guid currentPlayerId,
|
||||
IReadOnlyDictionary<string, object>? parameters = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -198,6 +198,60 @@ public class ShitGameLogicService : IGameLogicService
|
|||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<GameActionDescriptor> GetAvailableActions(object gameModel, Guid currentPlayerId)
|
||||
{
|
||||
var model = CastModel(gameModel);
|
||||
|
||||
if (model.IsGameOver)
|
||||
return [];
|
||||
|
||||
// Pass action only available when player has collected points
|
||||
if (model.CollectedPoints > 0)
|
||||
{
|
||||
return
|
||||
[
|
||||
new GameActionDescriptor
|
||||
{
|
||||
ActionId = "pass",
|
||||
Label = "Passen",
|
||||
Icon = "SkipNext",
|
||||
Color = "Warning",
|
||||
Tooltip = $"Gesammelte Punkte ({model.CollectedPoints}) an nächsten Spieler weitergeben",
|
||||
Order = 10
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public GameActionResult ExecuteAction(
|
||||
object gameModel,
|
||||
string actionId,
|
||||
Guid currentPlayerId,
|
||||
IReadOnlyDictionary<string, object>? parameters = null)
|
||||
{
|
||||
return actionId switch
|
||||
{
|
||||
"pass" => ExecutePassAction(gameModel, currentPlayerId),
|
||||
_ => GameActionResult.Failure($"Unknown action: {actionId}")
|
||||
};
|
||||
}
|
||||
|
||||
private GameActionResult ExecutePassAction(object gameModel, Guid currentPlayerId)
|
||||
{
|
||||
var (updatedModel, throwResult) = ProcessPass(gameModel, currentPlayerId);
|
||||
|
||||
return GameActionResult.SuccessResult(
|
||||
updatedModel,
|
||||
shouldRotate: throwResult.ShouldRotatePlayer,
|
||||
isGameOver: throwResult.IsGameOver,
|
||||
winnerId: throwResult.WinnerId,
|
||||
triggers: throwResult.Triggers);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public GameSetupValidationResult ValidateSetup(object? setupOptions)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -106,6 +106,23 @@ public class TrainingGameLogicService : IGameLogicService
|
|||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<GameActionDescriptor> GetAvailableActions(object gameModel, Guid currentPlayerId)
|
||||
{
|
||||
// Training has no special actions
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public GameActionResult ExecuteAction(
|
||||
object gameModel,
|
||||
string actionId,
|
||||
Guid currentPlayerId,
|
||||
IReadOnlyDictionary<string, object>? parameters = null)
|
||||
{
|
||||
return GameActionResult.Failure($"Action '{actionId}' is not supported in Training mode.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public GameSetupValidationResult ValidateSetup(object? setupOptions)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
@using Fluxor
|
||||
@using Koogle.Application.Games
|
||||
@using Koogle.Web.Store.GameState
|
||||
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
|
||||
@implements IDisposable
|
||||
|
||||
@inject IState<GameState> GameState
|
||||
@inject IDispatcher Dispatcher
|
||||
@inject GameDefinitionRegistry GameRegistry
|
||||
|
||||
@if (_availableActions.Count > 0)
|
||||
{
|
||||
<div class="game-action-panel">
|
||||
@foreach (var action in _availableActions.OrderBy(a => a.Order))
|
||||
{
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="@GetColor(action.Color)"
|
||||
StartIcon="@GetIcon(action.Icon)"
|
||||
Disabled="@(!action.IsEnabled || GameState.Value.IsSaving || GameState.Value.IsLoading)"
|
||||
OnClick="@(() => ExecuteAction(action.ActionId))"
|
||||
Title="@action.Tooltip"
|
||||
Class="game-action-button">
|
||||
@action.Label
|
||||
</MudButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<style>
|
||||
.game-action-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
background: var(--mud-palette-surface);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.game-action-button {
|
||||
min-width: 120px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<GameActionDescriptor> _availableActions = [];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
GameState.StateChanged += OnGameStateChanged;
|
||||
UpdateAvailableActions();
|
||||
}
|
||||
|
||||
private void OnGameStateChanged(object? sender, EventArgs e)
|
||||
{
|
||||
UpdateAvailableActions();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void UpdateAvailableActions()
|
||||
{
|
||||
_availableActions = [];
|
||||
var state = GameState.Value;
|
||||
|
||||
if (!state.IsGameActive || state.GameModel == null ||
|
||||
string.IsNullOrEmpty(state.GameTypeName) ||
|
||||
!state.Participants.CurrentPlayerId.HasValue)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var logicService = GameRegistry.GetLogicService(state.GameTypeName);
|
||||
_availableActions = logicService.GetAvailableActions(
|
||||
state.GameModel,
|
||||
state.Participants.CurrentPlayerId.Value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore - game type might not support actions
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteAction(string actionId)
|
||||
{
|
||||
Dispatcher.Dispatch(new ExecuteGameActionAction(actionId));
|
||||
}
|
||||
|
||||
private static Color GetColor(string colorName) => colorName switch
|
||||
{
|
||||
"Primary" => Color.Primary,
|
||||
"Secondary" => Color.Secondary,
|
||||
"Warning" => Color.Warning,
|
||||
"Error" => Color.Error,
|
||||
"Success" => Color.Success,
|
||||
"Info" => Color.Info,
|
||||
_ => Color.Primary
|
||||
};
|
||||
|
||||
private static string? GetIcon(string? iconName) => iconName switch
|
||||
{
|
||||
"SkipNext" => Icons.Material.Filled.SkipNext,
|
||||
"Stop" => Icons.Material.Filled.Stop,
|
||||
"PlayArrow" => Icons.Material.Filled.PlayArrow,
|
||||
"Pause" => Icons.Material.Filled.Pause,
|
||||
"Check" => Icons.Material.Filled.Check,
|
||||
"Close" => Icons.Material.Filled.Close,
|
||||
"Add" => Icons.Material.Filled.Add,
|
||||
"Remove" => Icons.Material.Filled.Remove,
|
||||
"Refresh" => Icons.Material.Filled.Refresh,
|
||||
_ => iconName // Allow passing full icon path
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GameState.StateChanged -= OnGameStateChanged;
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,9 @@
|
|||
<UndoRedoButtons IsDisabled="@GameState.Value.IsSaving" />
|
||||
</div>
|
||||
|
||||
@* Game-specific actions *@
|
||||
<GameActionPanel />
|
||||
|
||||
@* Error display *@
|
||||
@if (!string.IsNullOrEmpty(GameState.Value.Error))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@
|
|||
<ul style="margin: 8px 0 0 16px; padding: 0;">
|
||||
<li>Jeder Spieler startet mit der Start-Punktzahl</li>
|
||||
<li>Geworfene Kegel werden von deinen Punkten abgezogen</li>
|
||||
<li>Du kannst weiterwürfeln oder zum nächsten Spieler übergeben</li>
|
||||
<li>Wirfst du die Scheiss-Zahl oder in die Rinne, bekommst du alle gesammelten Punkte dazu</li>
|
||||
<li>Du kannst weiterspielen oder zum nächsten Spieler übergeben</li>
|
||||
<li>Wirfst du die Scheiss-Zahl oder in die Gosse, bekommst du alle gesammelten Punkte dazu</li>
|
||||
<li>Wer zuerst 0 erreicht, gewinnt!</li>
|
||||
<li>Die Verlierer zahlen Strafe basierend auf ihren Restpunkten</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -265,3 +265,33 @@ public record ClearGameErrorAction;
|
|||
/// Action to update the game-specific model.
|
||||
/// </summary>
|
||||
public record UpdateGameModelAction(object? GameModel);
|
||||
|
||||
// Game Action Execution Actions
|
||||
|
||||
/// <summary>
|
||||
/// Action to execute a game-specific action (e.g., pass in Shit game).
|
||||
/// </summary>
|
||||
/// <param name="ActionId">The action ID to execute.</param>
|
||||
/// <param name="Parameters">Optional action parameters.</param>
|
||||
public record ExecuteGameActionAction(
|
||||
string ActionId,
|
||||
IReadOnlyDictionary<string, object>? Parameters = null);
|
||||
|
||||
/// <summary>
|
||||
/// Action dispatched when game action succeeds.
|
||||
/// </summary>
|
||||
/// <param name="UpdatedGameModel">Updated game model after action.</param>
|
||||
/// <param name="ShouldRotatePlayer">Whether to rotate to next player.</param>
|
||||
/// <param name="IsGameOver">Whether game has ended.</param>
|
||||
/// <param name="WinnerId">Winner ID if game is over.</param>
|
||||
public record ExecuteGameActionSuccessAction(
|
||||
object? UpdatedGameModel,
|
||||
bool ShouldRotatePlayer,
|
||||
bool IsGameOver,
|
||||
Guid? WinnerId);
|
||||
|
||||
/// <summary>
|
||||
/// Action dispatched when game action fails.
|
||||
/// </summary>
|
||||
/// <param name="Error">Error message.</param>
|
||||
public record ExecuteGameActionFailureAction(string Error);
|
||||
|
|
|
|||
|
|
@ -418,6 +418,65 @@ public class GameEffects
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles ExecuteGameActionAction - executes game-specific action via IGameLogicService.
|
||||
/// </summary>
|
||||
[EffectMethod]
|
||||
public Task HandleExecuteGameAction(ExecuteGameActionAction action, IDispatcher dispatcher)
|
||||
{
|
||||
var state = _gameState.Value;
|
||||
var gameTypeName = state.GameTypeName;
|
||||
var currentPlayerId = state.Participants.CurrentPlayerId;
|
||||
|
||||
if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue || state.GameModel == null)
|
||||
{
|
||||
dispatcher.Dispatch(new ExecuteGameActionFailureAction("Cannot execute action: missing game state"));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var gameLogicService = _gameRegistry.GetLogicService(gameTypeName);
|
||||
var result = gameLogicService.ExecuteAction(
|
||||
state.GameModel,
|
||||
action.ActionId,
|
||||
currentPlayerId.Value,
|
||||
action.Parameters);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
dispatcher.Dispatch(new ExecuteGameActionSuccessAction(
|
||||
result.UpdatedModel,
|
||||
result.ShouldRotatePlayer,
|
||||
result.IsGameOver,
|
||||
result.WinnerId));
|
||||
|
||||
// Debounce save operations
|
||||
_saveDebounceTimer?.Dispose();
|
||||
_saveDebounceTimer = new Timer(
|
||||
async _ => await DebouncedSaveAsync(dispatcher),
|
||||
null,
|
||||
SaveDebounceMs,
|
||||
Timeout.Infinite);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Game action executed: {ActionId}, rotate={Rotate}, gameOver={GameOver}",
|
||||
action.ActionId, result.ShouldRotatePlayer, result.IsGameOver);
|
||||
}
|
||||
else
|
||||
{
|
||||
dispatcher.Dispatch(new ExecuteGameActionFailureAction(result.Error ?? "Action failed"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error executing game action: {ActionId}", action.ActionId);
|
||||
dispatcher.Dispatch(new ExecuteGameActionFailureAction($"Error: {ex.Message}"));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static ThrowPanelSnapshot CreateThrowPanelSnapshot(ThrowPanelState state)
|
||||
{
|
||||
return ThrowPanelSnapshot.FromPins(
|
||||
|
|
|
|||
|
|
@ -533,6 +533,76 @@ public static class GameReducers
|
|||
GameModel = action.GameModel
|
||||
};
|
||||
|
||||
// Game Action Reducers
|
||||
|
||||
/// <summary>
|
||||
/// Handles ExecuteGameActionAction - creates undo snapshot before action is processed.
|
||||
/// </summary>
|
||||
[ReducerMethod]
|
||||
public static GameState OnExecuteGameAction(GameState state, ExecuteGameActionAction action)
|
||||
{
|
||||
// Create snapshot of current state before action is processed
|
||||
var clonedGameModel = CloneGameModel(state.GameModel);
|
||||
|
||||
var snapshot = new GameSnapshot
|
||||
{
|
||||
ThrowPanelAfter = state.ThrowPanelAfter,
|
||||
Participants = state.Participants,
|
||||
GameModel = clonedGameModel
|
||||
};
|
||||
|
||||
return state with
|
||||
{
|
||||
UndoStack = state.UndoStack.Add(snapshot),
|
||||
RedoStack = [], // Clear redo stack - new action invalidates redo history
|
||||
IsLoading = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles ExecuteGameActionSuccessAction - applies action result with player rotation.
|
||||
/// </summary>
|
||||
[ReducerMethod]
|
||||
public static GameState OnExecuteGameActionSuccess(GameState state, ExecuteGameActionSuccessAction action)
|
||||
{
|
||||
var newState = state with
|
||||
{
|
||||
GameModel = action.UpdatedGameModel,
|
||||
IsLoading = false,
|
||||
IsSaving = true
|
||||
};
|
||||
|
||||
// Apply player rotation if requested
|
||||
if (action.ShouldRotatePlayer)
|
||||
{
|
||||
newState = newState with
|
||||
{
|
||||
Participants = newState.Participants.NextPlayer()
|
||||
};
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles ExecuteGameActionFailureAction - sets error state and removes snapshot.
|
||||
/// </summary>
|
||||
[ReducerMethod]
|
||||
public static GameState OnExecuteGameActionFailure(GameState state, ExecuteGameActionFailureAction action)
|
||||
{
|
||||
// Remove the snapshot we added in OnExecuteGameAction since action failed
|
||||
var newUndoStack = state.UndoStack.Count > 0
|
||||
? state.UndoStack.RemoveAt(state.UndoStack.Count - 1)
|
||||
: state.UndoStack;
|
||||
|
||||
return state with
|
||||
{
|
||||
UndoStack = newUndoStack,
|
||||
IsLoading = false,
|
||||
Error = action.Error
|
||||
};
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Reference in New Issue