Blazor Server Fluxor 实现 2048





0/5 (0投票)
由 Blazor Server 托管并使用 Fluxor 框架实现的 2048 瓷砖滑动游戏
引言
本文详细介绍了一个 2048 方块滑动游戏的 Blazor Server 实现,该实现采用了面向消息中间件的 Fluxor.Blazor.Web Nuget 包。本文的重点将放在如何将多个 Blazor 组件集成到 Fluxor 模式中。游戏引擎(用于推进游戏进程)在之前的一篇文章中已有描述。
Blazor Server Web 托管模式
如果您是 Blazor 的新手,了解 Blazor Server 托管模式通过使用组件来构建网页可能会有所帮助。组件通常是包含 HTML 标记和代码的实体,用于处理 Razor 页面所承载的用户界面的特定方面。应用程序所需的任何处理都在服务器上完成,组件通过更新用户界面 (UI) 来响应这些活动。服务器和组件之间的通信由框架处理,因此通常不需要编写专门的 JavaScript 来实现它。
实现 Fluxor 模式
与 Blazor 组件相关的变量状态通常在组件超出作用域后不会持久化,因此当组件重新进入作用域时,它们会恢复为默认值。Fluxor 模式通过提供和管理 State
类,在整个应用程序中持久化组件的状态。Fluxor 还使组件能够仅作为用户界面,而无需了解游戏引擎和任何外部组件。组件通常引用一个持有 UI 变量的 State
类,但组件不直接更新 State
类。由组件发起的任何操作都会通过消息分派给一个 Effects
类。Effects
类处理该操作所需的任何任务。由这些任务引起的组件状态变化随后会通过消息分派给一个 Reducers
类。Reducer 方法接收消息中待处理的更改和现有的 State
,并将它们组合成一个新的 State
实例。新的 State
实例会触发组件中的更新响应,组件会“重新渲染”。这看起来可能是一个冗长而繁琐的实现,但它在 Fluxor 定义的实体之间提供了极好的分离度。组件与外部世界的通信仅限于接收和发送消息,但它们对所接收消息的发送者或所分派消息的接收者一无所知。
一些设计考量
Fluxor 使用称为“用例”的独特执行路径来推进应用程序并方便调试。重要的设计考量是确定需要多少个用例,以及如何为每个用例最佳地应用 Fluxor 的事件、效应、状态变化模式。本示例应用程序设计为有两个用例。
- 一个
DashboardUseCase
。该用例管理一个DashboardComponent
及其相关进程,以显示与游戏状态相关的变量,并处理游戏的开始和结束。 - 一个
GameboardUseCase
。该用例关注游戏引擎的实现。这里的主要组件是GameboardComponent
,它用于容纳游戏所需的 4 行 4 列子TileComponents
。
还有另一个组件 NavigationComponent
,它控制在游戏板的水平和垂直平面上滑动方块的模拟。它是 GameboardUseCase
命名空间的成员,但它也会改变 DashboardUseCase
的状态。建议的用于存放 Fluxor 相关实体的文件结构是使用一个名为 Store 的父目录,并在其中创建以用例命名的子目录。这些子目录中包含一个 Effects
类、一个 Reducers
类和一个 State
类。消息类则放置在父目录中。
Razor 页面
组件托管在单个 Razor 页面上。该应用使用 Blazor 集成的 Bootstrap 来布局主要组件。
<h1>2048</h1>
<DashboardComponent />
<div class="container">
<div class="d-sm-flex flex-row justify-content-evenly">
<GameBoardComponent />
<NavigationComponent />
</div>
</div>
代码仅在首次渲染时初始化 DashboardComponent
和 GameboardComponent
,因为 DashboardResetAction
会导致状态的 IsInitialised
变量被设置为 true。Dispatcher
负责“发布”消息。
[Inject]
public IDispatcher? Dispatcher { get; set; }
[Inject]
private IState<DashboardState>? DashboardState { get; set; }
protected override void OnInitialized()
{
if(DashboardState!.Value.IsInitialised is false)
{
Dispatcher!.Dispatch(new DashboardResetAction());
Dispatcher.Dispatch(new GameBoardResetAction());
}
base.OnInitialized();
}
Dispatcher
使用的消息是 record
类型。记录是不可变的,因此每次使用时都需要构造一个新的消息。record
语法使其易于定义。
//A message without any properties
public record DashboardResetAction();
//A message with read only public properties
public record UpdateBoardAction(int NewTileId, bool IsRunning);
Gameboard 组件
@using BlazorServer2048.Store
@using BlazorServer2048.Components
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
<div class="container">
@for(int r=0;r<4;r++)
{
<div class="d-sm-flex flex-row">
@for(int c=0;c<4;c++)
{
<TileComponent Row="@r" Col="@c"/>
}
</div>
}
</div>
组件继承自 FluxorComponent
非常重要,因为它是 Fluxor 框架的用户界面基类。该组件使用嵌套的 for
循环来渲染 4 行 4 列的 TileComponents
。所有功能都包含在子 TileComponent
中。
TileComponent
可缩放矢量图形 (SVG) 用于显示一个矩形和一个居中于矩形内的文本类型。填充颜色和文本都是动态渲染的。
<div>
<svg width="100" height="100">
<rect x="0" y="0" width="100" height="100"
fill="@colourList[GameboardState!.Value.BoardTiles[Id].TileValue]"
stroke="white" stroke-width="8" />
<text x="50%" y="50%" dominant-baseline="middle" font-weight="bold"
text-anchor="middle">@GameboardState!.Value.BoardTiles[Id].FaceValueText</text>
</svg>
</div>
组件的后台类 BoardTile
有一个 TileValue
属性,它以 2 的幂的形式存储,范围为 0 到 11。它被用作索引来从 ColourList
中返回填充值。另一个属性 FaceValueText
使用其 getter 将 TileValue
转换为该幂的十进制值的相应字符串表示。当幂为 0 时返回一个空字符串,因为它代表一个没有值的空白方块。方块移动的模拟是通过在相邻方块之间转移方块的值来实现的。
public string FaceValueText
{
get
{
int faceValue = TileValue == 0 ? 0 : (1 << TileValue);//2 to the power of TileValue
return faceValue == 0 ? string.Empty : faceValue.ToString();
}
}
NavigationComponent
NavigationComponent
只是使用 Bootstrap 的 row
和 col
类布局的 4 个 Button
。
<div class="container" >
<div class="d-flex flex-column justify-content-center vertical-box" >
<div class="row" >
<h4 >Slide Control </h4 >
</div >
<div class="row" >
<div class="col-md-1 offset-md-1" >
<button type="button" class="btn btn-primary"
@onclick="((args) = > OnDirectionUpdated(Direction.Up))" >N </button >
</div >
</div >
<div class="row" >
<div class="col-md-1 " >
<button type="button" class="btn btn-primary"
@onclick="((args) = > OnDirectionUpdated(Direction.Left))" >W </button >
</div >
<div class="col-md-1 offset-md-1" >
<button type="button" class="btn btn-primary"
@onclick="((args) = > OnDirectionUpdated(Direction.Right))" >E </button >
</div >
</div >
<div class="row" >
<div class="col-md-1 offset-md-1" >
<button type="button" class="btn btn-primary"
@onclick="((args) = > OnDirectionUpdated(Direction.Down))" >S </button >
</div >
</div >
</div >
</div >
OnDirectionUpdated
方法分派一个 DirectionSelectedAction
,并将 Direction enum
的相应成员作为属性值。该消息的处理程序位于 GameboardUseCase Events
类内部。
[EffectMethod]
public Task HandleDirectionSelectedAction(DirectionSelectedAction action, IDispatcher dispatcher)
{
int score;
if (GameService.IsRunning)
{
(bool isRunning, score, int newTileId) = GameService.PlayMove(action.Direction);
dispatcher.Dispatch(new DirectionSelectedActionResult(isRunning, score + State.Value.Total));
//if new tile Id is -1 no tile is required
if (newTileId == -1) return Task.CompletedTask;
//let the GameboardUseCase Effects class update the board
dispatcher.Dispatch(new UpdateBoardAction(newTileId,isRunning));
}
return Task.CompletedTask;
}
DashboardComponent
当 DashboardState
发生变化时,DashboardComponent
会更新。但它也会响应接收到的 GameOverAction
消息,在游戏结束时显示一个对话框 (Modal)。它通过调用 FluxorComponent 的 SubscribeToAction
方法来订阅接收 GameOverAction
消息,并使用 Nuget 包 Blazored.Modal 来部署对话框。
CascadingParameter]
public IModalService? Modal { get; set; }
protected override void OnInitialized()
{
base.OnInitialized();
//need to resubscribe every time OnInitialized is called
SubscribeToAction<GameOverAction>(async (action) => await OnGameOver(action.Message));
}
private async Task OnGameOver(string msg)
{
var options = new ModalOptions { Position = ModalPosition.TopRight };
var modalComponent = Modal?.Show<ModalComponent>(msg, options);
if (modalComponent == null) return;
ModalResult result = await modalComponent.Result;
if (result.Confirmed)
{
StopStartSelector();
}
}
private void StopStartSelector()
{
Dispatcher!.Dispatch(new StopStartAction());
}
State 类
DashboardState
持有多个值类型。State 类需要有一个默认构造函数。
[FeatureState]
public record DashboardState(int Total,
bool IsRunning,
bool IsGameWon,
bool IsInitialised)
{
public DashboardState() : this(0, false, false,false)
{
}
}
GameboardState
需要存储 16 个 GameboardComponent
的后台类 BoardTile
的实例。文档建议为此使用 ImmutableArray<T>
。
[FeatureState]
public record GameboardState
{
public GameboardState()
{
var boardTiles = new List<BoardTile>(16);
for (int i = 0; i < 16; i++)
{
boardTiles.Add(new BoardTile(i, 0));
}
BoardTiles = ImmutableArray.Create(boardTiles.ToArray());
}
public ImmutableArray<BoardTile> BoardTiles { get; init; }
}
Reducers 类
DashboardComponent
的 Reducers
类利用 with
关键字来更新状态。
[ReducerMethod]
public static DashboardState HandleDirectionSelectedActionResult(
DashboardState state,
DirectionSelectedActionResult action)
{
return state with { IsRunning = action.IsRunning, Total=action.Total};
}
GameboardComponent
的 Reducers
类要复杂一些,因为它需要更新一个不可变数组。
[ReducerMethod]
public static GameboardState ReduceUpdateAllTilesActionResult(
GameboardState state,
UpdateAllTilesActionResult action)
{
var updatedTiles = state!.BoardTiles.ToArray();
foreach (var boardTile in state.BoardTiles)
{
if (action.ChangedTileValues!.TryGetValue(boardTile.Id, out int value))
{
updatedTiles[boardTile.Id] = new BoardTile(boardTile.Id, value);
}
}
var newArray = ImmutableArray.Create(updatedTiles);
return state with { BoardTiles = newArray };
}
有一个方法可以更新不可变数组中的单个项。它在移动完成后插入新方块时使用。
var updatedTiles= state!.BoardTiles.SetItem(
action.TileId, new BoardTile(action.TileId, action.TileValue));
return state with { BoardTiles = updatedTiles };
App.razor
Blazor 采用结构化的组件渲染树来更新页面。App.razor
是该树的根组件;默认情况下,它有一个名为 Router
的子组件。还需要添加另外两个组件,如下所示。
<Fluxor.Blazor.Web.StoreInitializer/>
<CascadingBlazoredModal>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingBlazoredModal>
需要 CascadingBlazoredModal
组件来启用模态弹出窗口。对 Fluxor.Blazor.Web.StoreInitializer
的引用是设置 Fluxor 框架所必需的。该框架在很大程度上依赖于在 Effects
、Reducers
和 State
类中设置适当的特性 (attribute)。决定类用途的是这些特性,而不是类名,但遵循文档中使用的名称可能是最佳选择。应用程序所需的其他服务未在此处包含,它们在 Program.cs 中添加。
Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddFluxor(options => options.ScanAssemblies(typeof(Program).Assembly));
builder.Services.AddSingleton<WeatherForecastService>();
//Add the GameService that drives the GameEngine
builder.Services.AddSingleton<IGameService, GameService>();
builder.Services.AddBlazoredModal();
var app = builder.Build();
结论。
将 Fluxor 的事件、效应、状态变化模式应用于预定义的执行路径,可以产生易于维护、调试和扩展的健壮应用程序。