65.9K
CodeProject 正在变化。 阅读更多。
Home

在线双陆棋

starIconstarIconstarIconstarIconstarIcon

5.00/5 (24投票s)

2021年3月27日

CPOL

8分钟阅读

viewsIcon

39080

downloadIcon

596

一款在线Angular、.NET 7 Web API、Azure上的SQL Server双陆棋游戏

目录

引言

了解如何在Azure上开发在线游戏。
或者直接在https://backgammon.azurewebsites.net/玩游戏。

背景

在过去的这一个月里,我大部分的空闲时间都用来构建一款在线双陆棋游戏。主要目标是尝试提高我的全栈开发技能,或许还能学到一两个新技巧。在本文中,我将分享我使用的技术以及一些我认为对您开始类似项目有用的东西。这绝不是我的代码的完整指南,您可以在GitHub上找到开源源代码(2)

游戏功能列表如下

  • 与随机对手或AI对战
  • 邀请好友
  • Elo等级分和排行榜
  • 移动端响应式设计

架构

该应用程序托管在Azure(10)的应用服务上,数据存储在SQL Server中。您可以通过Facebook或Google OAuth 2.0进行身份验证。后端使用C# .NET编写(最新.NET Core)。对于SQL Server数据库集成,我使用Entity Framework Core进行代码优先迁移。

游戏过程中,前端和后端之间的通信通过WebSockets进行,其他所有通信则通过REST API进行。

前端我使用Angular 15,游戏棋盘绘制在HTML canvas元素上。

规则

双陆棋的规则(3)乍一看可能很简单:掷骰子,然后按照骰子的点数移动棋子到您的主区域。如果对手在某个点上有两个或更多棋子,则该点被阻塞。如果对手在一个点上只有一个棋子,您可以打它,该棋子将被移到吧台,被迫从零点开始。但也有一些复杂的情况,例如,如果可能,您必须总是使用两个骰子。如果一个棋子的移动阻止了使用另一个骰子,您就不能移动该棋子。
在此处阅读更多关于双陆棋规则的信息。出于这些原因,我决定使用测试驱动开发(TDD)来开发游戏规则,并将其保留在一个单独的DLL中。当计算变得复杂并且您不想花费数小时甚至数天来追踪棘手的错误时,TDD是我的首选方法。

我早就意识到游戏状态必须保存在服务器端,而客户端应尽可能少地包含游戏规则。游戏规则是用C#开发的,是后端的一部分。一个主要目标是尽量减少网络流量,以防许多用户同时开始玩游戏。更大的服务器在Azure上的成本更高。

由于骰子的掷法是随机的,Rules.Game类还拥有一个用于测试的FakeRoll函数,该函数当然不能从客户端访问。以下是Rules.Game类的一些测试用例示例。

[TestMethod]
public void TestMoveGeneration()
{ 
    game.FakeRoll(1, 2);
    var moves = game.GenerateMoves();
    Assert.AreEqual(7, moves.Count);
}

[TestMethod]
public void TestCheckerOnTheBarBlocked()
{
    game.AddCheckers(2, Player.Color.Black, 0);
    game.FakeRoll(6, 6);
    var moves = game.GenerateMoves();
    Assert.AreEqual(0, moves.Count);
}

少一个Web服务器

客户端通过Web API连接到服务器。由于API由应用服务提供,而应用服务本质上是一个Web服务器,我想在生产环境中也使用同一个应用服务来提供Angular客户端。这样您就少了一个Web服务器需要担心。另一种选择可能是例如在Ubuntu机器上使用Nginx。

这是使其工作所需的配置。

public void Configure(IApplicationBuilder app, IWebHostEnvironment env, 
       ILogger<GameManager> logger, IHostApplicationLifetime applicationLifetime)
{            
    app.UseHttpsRedirection();
    app.UseRouting();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
    app.UseWebSockets();
    app.UseDefaultFiles();            
    app.Use(async (context, next) =>
        {
            if (context.Request.Path == "/ws/game")
            {
                if (context.WebSockets.IsWebSocketRequest)
                {
                    logger.LogInformation($"New web socket request.");                        
                    // Handle web socket stuff. See example below.
                }
                else
                {
                    context.Response.StatusCode = 400;
                }
            }
            else
            {
                await next();
                // This enables angular routing to function on the same app as the web socket.
                // If there's no available file and the request doesn't contain an extension,
                // we're probably trying to access a page.
                // Rewrite request to use app root
                if (SinglePageAppRequestCheck(context))
                {
                    context.Request.Path = "/index.html";
                    await next();
                }
            }
        });
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
        // Required for serving the Angular app in wwwroot
        app.UseStaticFiles();
   }

Websockets

WebSockets是我以前从未接触过的技术,但我对此非常好奇。我熟悉Windows上的常规套接字,所以我明白通信实际上比常规的请求-响应模式更简单。WebSocket与HTTP请求的区别在于,套接字始终是打开的。客户端或服务器都可以随时发送数据。

Angular和.NET Core都有很好的库,而且它们完全兼容。唯一可能感觉奇怪的是,当.NET服务器端的函数接受套接字返回时,连接就会关闭。所以您必须在一个循环中读取套接字,直到您决定关闭通信。套接字也封装在HTTP请求中,直到套接字关闭才返回。

以下是我的Startup配置函数,稍微简化了一些。

if (context.Request.Path == "/ws/game")
{
    if (context.WebSockets.IsWebSocketRequest)
    {
        var socket = await context.WebSockets.AcceptWebSocketAsync();                        
        try
        {
            while (socket.State != WebSocketState.Closed &&
                   socket.State != WebSocketState.Aborted &&
                   socket.State != WebSocketState.CloseReceived)
            {
                 var buffer = new byte[512];
                 var sb = new StringBuilder();
                 WebSocketReceiveResult result = null;
                 // reading everything on the socket
                 while (result == null || 
                       (!result.EndOfMessage && !result.CloseStatus.HasValue))
                 {
                     result = await socket.ReceiveAsync
                              (new ArraySegment<byte>(buffer), CancellationToken.None);
                     var text = Encoding.UTF8.GetString(buffer.Take(result.Count).ToArray());
                     sb.Append(text);
                 }
                 // Do something with the data here.
             }
             logger.LogInformation("Socket is closed");
         }
         catch (Exception exc)
         {
             logger.LogError(exc.ToString());
         }
    }
    else
    {
         context.Response.StatusCode = 400;
    }
}

登录

后端知道玩家是谁并不重要。重要的是识别用户是否是老用户,以便在不同玩家竞争时保留分数。对于这些需求,我认为使用外部社交提供商进行身份验证是完美的。我启用了Facebook和Google提供商。

我看不出用户每次启动应用程序时都需要登录的理由,所以登录UserDto存储在浏览器的本地存储中。以下是登录过程中发生的步骤。

  1. 用户点击Google或Facebook登录按钮。
  2. 调用Angular包angularx-social-login(6)上的signIn函数。
  3. 登录模态框打开。
  4. 返回一个SocialUser对象,其中包含一个OpenId jwt
  5. jwt被安全地发送到后端进行验证。
  6. 如果有效,则创建一个用户(如果尚未创建)。
  7. 用户的唯一用户ID被发送回客户端并存储在本地存储中。用户现在已登录,可以与其他用户对战。

绘制棋盘

绘制我认为是应用程序中最有趣的部分。如果您习惯于以x-y坐标思考,那么canvas绘制(7)会相当容易。主要优点是您可以使棋盘100%响应式,因此它可以很好地适应任何屏幕尺寸。也就是说,如果您相对于屏幕的高度和宽度计算所有坐标。您可以通过这种方式获取绘图上下文并在其上绘制一个填充的圆。

// typescript

  @ViewChild('canvas') public canvas: ElementRef | undefined;
  ngAfterViewInit(): void {
    const canvasEl: HTMLCanvasElement = this.canvas.nativeElement;
    const cx = canvasEl.getContext('2d');
    cx.beginPath();
    cx.ellipse(x, y, width, width, 0, 0, 2 * Math.PI);
    cx.closePath();
    cx.fill();
  }

我学到的一件事是使用内置函数:requestAnimationFrame。每次棋盘上发生变化时都会调用它,然后由浏览器决定它是否有时间绘制一个帧。我认为使用这种方法对CPU的影响非常低。

requestAnimationFrame(this.draw.bind(this));

实体框架

我很高兴看到Entity Framework(9)在过去几年中的发展。如今,编写一些C#类,确保每个类都有一个主键,然后让Entity Framework为您生成一切,变得非常容易。我惊讶于EF Core在实现多对多关系等方面做得如此好。我也喜欢名为"Id"且datatype int的属性会自动定义为自动递增标识。

错误消息对我来说一直很清晰和描述性,所以这是我花费时间最少的一部分开发。

每次我需要进行数据库更新时,我只需添加一些数据类、属性或其他内容,然后调用

Add-Migration a-name-I-choose
(Inspect the changes in the generated migration files)

Update-Database
(done)

数据传输对象

在所有客户端-服务器应用程序中,给予集成可能的部分额外的时间和思考非常重要。客户端和服务器本质上是两个不同的程序,它们经常有不同的开发节奏。为了尽量减少在一个端更改可能导致集成中断的风险,您需要使用数据传输对象(dto)。它们的目的是定义在客户端和服务器之间传输的数据。由于它们是用不同的语言编写的,我使用了一个名为Cody Schrank的MTT(8)的包。它在.csproj文件中这样设置:

<Target Name="Convert" BeforeTargets="PrepareForBuild">
    <ConvertMain WorkingDirectory="Dto/" ConvertDirectory="tsdto/" />
</Target>

MTT将C# dtos在编译时转换为typescript接口,然后这些接口被用作客户端和服务器中发送或接收数据的定义。不幸的是,MTT只能将文件保存在项目目录下方,所以我还需要将它们复制到客户端文件源树中。

转换示例

namespace Backend.Dto
{
    public class CheckerDto
    {
        public PlayerColor color { get; set; }
    }
}

被转换为typescript

import { PlayerColor } from "./playerColor";

export interface CheckerDto {
    color: PlayerColor;
}

AI

还有一个AI,如果您找不到人类对手,可以与之对战。如果您想了解其中的细节,我在这里写了一篇单独的文章

结束语

如果您觉得这篇文章很有趣,但对您会选择哪些技术有自己的看法,或有任何其他评论,请在下面的评论中告诉我。

如果您在游戏中发现bug,也请告诉我。代码是公开的,任何人都可以阅读,所以如果您想提交一个pull request并帮助改进,我们非常欢迎。

请不要忘记投票。 ;)

我还要感谢Shane、Patrik和Linn在测试方面的帮助。

链接

历史

  • 2021年3月24日:版本1.0
  • 2021年4月4日:版本1.1
    • 与Aina AI对战
  • 2021年4月9日:版本1.1.1
    • 错误修正
  • 2021年4月30日:版本1.2
    • 应用程序更新了翻译、声音以及许多小功能和bug修复。
  • 2021年6月6日:版本3.0
    • 金牌对局
    • 玩家图片
  • 2022年2月9日:版本3.4.1
    • 规则教程
    • 修复了部分触摸设备的bug
  • 2022年4月25日:版本3.6
    • AI Bug修复(感谢Hans-Jürgen发现此bug)
    • AI逻辑小幅改进,以实现更好的“落盘”
    • 如果您玩练习游戏,可以请求提示一个好的走法。
  • 2022年11月19日:版本4.0
    • 与其他玩家聊天
© . All rights reserved.