SignalR + KnockoutJS + ASP.NET MVC4 實現井字遊戲

1.1.1 摘要

今天,咱們將使用SignalR + KnockoutJS + ASP.NET MVC實現一個實時HTML5的井字棋遊戲。css

首先,網絡遊戲平臺必定要讓用戶登錄進來,因此須要一個登錄模塊,而後就是遊戲設計而且在遊戲過程當中保持用戶鏈接有效性,假設用戶玩着玩着忽然掉線這確定會使用戶很不爽;所以,保持客戶端和服務端通信的穩定性變得相當重要了,這裏咱們將使用SignalR和Html5保持通信實時和穩定。html

近1、兩年來HTML5的發展是沸沸揚揚,在這其中你也許聽過HTML5的規劃給瀏覽器與服務器之間進行全雙工通信的WebSocket的通信協定,並提供了的WebSocket API,這一套完整的API設計,在規格的部分,WebSocket的通信協定已經於2011年被IETF(國際網路工程研究團隊)定爲標準的RFC 6455,而的WebSocket API則被W3C定爲標準。目前各平臺的瀏覽器的主流版本皆已經支援HTML5的WebSocket / WebSocket API。c++

WebSocket / WebSocket API企圖解決開發者長久以來實現服務器推送技術幾乎都依賴於輪詢的方式所形成的明顯缺點,使得服務器接受到太多請求,致使服務器資源過分佔用以及帶寬的浪費。git

那麼,咱們使用WebSocket / WebSocket API就能夠確保客戶端和服務器通信的穩定性,但咱們要面對一個事實是否是每一個用戶的瀏覽器都支持HTML5,咱們必須提升舊的瀏覽器支持方案。github

SignalR的出現讓ASP.NET的開發者獲得了救贖,兼容的通信協議設計將Comet Programming概念和WebSocket技術都放在SignalR整個通信架構中;SignalR會針對目前執行的瀏覽器進行判斷,找到客戶端(瀏覽器)與服務器最合適的創建連接方式。web

SignalR會優先選用WebSocket技術與服務器溝通,開發人員就不須要針對瀏覽器而作出特殊的處理,全部的代碼都經過ASP.NET SignalR高級的API進行信息傳遞。redis

signalr0

圖1 SignalR通信的連接方式數據庫

目錄

1.1.2 正文

首先,咱們將使用ASP.NET MVC和SignalR實現服務端,客戶端使用KnockoutJS和Html5獲取和綁定數據到頁面,具體設計以下圖:bootstrap

 signalr5

圖2 井字棋遊戲設計canvas

咱們使用SignalR提供一個簡單的API用於建立服務器端到客戶端的遠程過程調用(RPC),以便從服務器端.NET代碼中調用客戶端瀏覽器(以及其餘客戶端平臺)中的JavaScript函數;客戶端瀏覽器也能夠經過SigalR來調用服務端.NET代碼。

ASP.NET MVC4 服務端

接下來,咱們要實現.NET服務器端,因爲咱們遊戲平臺是讓用戶登錄後進行遊戲的,因此咱們將實現用戶賬戶管理的模塊。

首先,咱們建立一個ASP.NET MVC4 Web Application。

 signalr6

圖3 ASP.NET MVC4 Web Application

這裏咱們選擇Empty模板就能夠了。

 signarl7

圖4 ASP.NET MVC4 Web Application

而後,咱們在項目中使用如下Nuget包:

  • install-package Microsoft.AspNet.SignalR
  • install-package jQuery
  • install-package KnockoutJS

用戶權限管理

咱們知道ASP.NET MVC自帶的權限表的建立是在InitializeSimpleMembershipAttribute.cs中實現的,因此咱們在程序中添加Filters文件夾,而後建立InitializeSimpleMembershipAttribute類,具體定義以下:

namespace OnlineTicTacTor.Filters
{
    /// <summary>
    /// Simple Membership initializer.
    /// </summary>
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
    public sealed class InitializeSimpleMembershipAttribute : ActionFilterAttribute
    {
        private static SimpleMembershipInitializer _initializer;
        private static object _initializerLock = new object();
        private static bool _isInitialized;

        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            // Ensure ASP.NET Simple Membership is initialized only once per app start

            LazyInitializer.EnsureInitialized(ref _initializer, ref _isInitialized, ref _initializerLock);
        }

        private class SimpleMembershipInitializer
        {
            public SimpleMembershipInitializer()
            {
                Database.SetInitializer<UsersContext>(null);

                try
                {
                    using (var context = new UsersContext())
                    {
                        if (!context.Database.Exists())
                        {
                            // Create the SimpleMembership database without Entity Framework migration schema
                            ((IObjectContextAdapter)context).ObjectContext.CreateDatabase();
                        }
                    }

                    WebSecurity.InitializeDatabaseConnection("DefaultConnection", "UserProfile", "UserId", "UserName", autoCreateTables: true);
                }
                catch (Exception ex)
                {
                    throw new InvalidOperationException("The ASP.NET Simple Membership database could not be initialized.", ex);
                }
            }
        }
    } 
}

上面,咱們定義了Web.ConfigInitializeSimpleMembershipAttribute類,接着咱們在Web.config中的配置數據庫。

  <connectionStrings>
    <add name="DefaultConnection" connectionString="Data Source=(LocalDb)\v11.0;Initial Catalog=GamesDB;Integrated Security=SSPI;AttachDBFilename=|DataDirectory|\GamesDB.mdf" providerName="System.Data.SqlClient" />
  </connectionStrings>

咱們定義了數據庫GamesDB,如今咱們運行整個項目,看到localdb中生成了GamesDB數據庫。

  signarl8

圖5 GamesDB數據庫

因爲,咱們使用ASP.NET MVC自帶的權限表來管理用戶帳號,這裏會使用到表UserProfile和webpages_Membership,固然,若是有更復雜的權限管理,咱們可使用表webpages_Roles和webpages_UsersInRoles等。

如今,咱們已經建立了數據庫GamesDB,接下來定義對應於數據表的DTO,首先,咱們在Models文件夾中建立AccountModels.cs文件,而後定義類LoginModel具體定義以下:

    /// <summary>
    /// The DTO for user account.
    /// </summary>
    public class LoginModel
    {
        [Required]
        [Display(Name = "User name")]
        public string UserName { get; set; }

        [Required]
        [DataType(DataType.Password)]
        [Display(Name = "Password")]
        public string Password { get; set; }

        [Display(Name = "Remember me?")]
        public bool RememberMe { get; set; }
    }

 

上面,咱們定義了數據傳輸類LoginModel,它包含了UserName、Password和RememberMe等信息。

接下來,咱們在Account文件中建立用戶登錄頁面Login.cshtml,因爲時間的關係咱們已經把頁面設計好了,具體定下以下:

@model OnlineTicTacTor.Models.LoginModel

@{
    ViewBag.Title = "Login";
}


<section>
    @using (Html.BeginForm(new { ReturnUrl = ViewBag.ReturnUrl }))
    {
        @Html.AntiForgeryToken();
        @Html.ValidationSummary();
        <div class="container">
            <div class="content">
                <div class="form-group">
                    @Html.LabelFor(m => m.UserName)
                    @Html.TextBoxFor(m => m.UserName, new { @class = "form-control", @placeholder = "UserName" })
                    @Html.ValidationMessageFor(m => m.UserName)
                </div>
                <div class="form-group">
                    @Html.LabelFor(m => m.Password)
                    @Html.PasswordFor(m => m.Password, new { @class = "form-control", @placeholder = "Password" })
                    @Html.ValidationMessageFor(m => m.Password)
                </div>
                <div class="checkbox">
                    @Html.CheckBoxFor(m => m.RememberMe)
                    @Html.LabelFor(m => m.RememberMe, new { @class = "checkbox" })
                </div>
                <button type="submit" class="btn btn-primary">Login</button>
            </div>
            <p>
                @Html.ActionLink("Register", "Register") if you don't have an account.
            </p>
        </div>
    }
</section>

咱們在登錄頁面定義了用戶名、密碼、登錄按鈕和註冊帳號超連接等控件。

signarl10

圖6 登錄頁面

接下來,咱們一樣在Account文件中建立用戶註冊頁面Register.cshtml,具體定義以下:

@*The Register view.*@
@model OnlineTicTacTor.Models.RegisterModel
@{
    ViewBag.Title = "Register";
}

@using (Html.BeginForm())
{
    
 @Html.AntiForgeryToken()
 @Html.ValidationSummary()

    <div class="container">
        <div class="content">
            <div class="form-group">
                @Html.LabelFor(m => m.UserName)
                @Html.TextBoxFor(m => m.UserName, new { @class = "form-control", @placeholder = "UserName" })
            </div>
            <div class="form-group">
                @Html.LabelFor(m => m.Password)
                @Html.PasswordFor(m => m.Password, new { @class = "form-control", @placeholder = "Password" })
            </div>
            <div class="form-group">
                @Html.LabelFor(m => m.ConfirmPassword)
                @Html.PasswordFor(m => m.ConfirmPassword, new { @class = "form-control", @placeholder = "ConfirmPassword" })
            </div>
            <button type="submit" class="btn btn-primary">Register</button>
        </div>
    </div>
}

 signarl11

圖7 註冊頁面

如今,咱們已經實現了用戶註冊和登錄的頁面了,接下來,咱們要把用戶註冊和登錄數據提交到數據庫中。

咱們在Controllers文件夾中建立AccountController類,在其中分別定義註冊和登錄方法,具體定義以下:

/// <summary>
/// Logins with LoginModel.
/// </summary>
/// <param name="model">The user information.</param>
/// <param name="returnUrl">The return URL.</param>
/// <returns></returns>
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public ActionResult Login(LoginModel model, string returnUrl)
{
    if (ModelState.IsValid && WebSecurity.Login(model.UserName, model.Password, persistCookie: model.RememberMe))
    {
        return RedirectToLocal(returnUrl);
    }

    // If we got this far, something failed, redisplay form
    ModelState.AddModelError("", "The user name or password provided is incorrect.");
    return View(model);
}


/// <summary>
/// Registers with LoginModel.
/// </summary>
/// <param name="model">The user information.</param>
/// <returns></returns>
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public ActionResult Register(RegisterModel model)
{
    if (ModelState.IsValid)
    {
        // Attempt to register the user
        try
        {
            WebSecurity.CreateUserAndAccount(model.UserName, model.Password);
            WebSecurity.Login(model.UserName, model.Password);
            return RedirectToAction("Login", "Account");
        }
        catch (MembershipCreateUserException e)
        {
            ModelState.AddModelError("", ErrorCodeToString(e.StatusCode));
        }
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

上面,咱們定義了方法Register ()和 Login(),經過Register()方法把用戶信息保存到表UserProfile和webpages_Membership中;Login()方法判斷該用戶信息是否有效。

如今,咱們在Register頁面中輸入帳號名JKhuang和密碼,當咱們點擊建立帳號以後,在數據表中查看到已經建立相應的帳號。

 

signarl12

signarl13

圖8 用戶信息表

遊戲對象模型

如今,咱們已經完成了登錄功能了,接下來咱們將實現井字遊戲功能;首先,在Models文件夾中建立類ConnectionSession和UserCredential。

/// <summary>
/// The connection session model
/// </summary>
public class ConnectionSession
{
    public int Id { get; set; }
    public string ConnectionId { get; set; }
    public long ConnectedTime { get; set; }
    public long DisconnectedTime { get; set; }
}

/// <summary>
/// The user credential model.
/// </summary>
public class UserCredential
{
    public int Id { get; set; }
    public string UserId { get; set; }
    public ConnectionStatus ConnectionStatus { get; set; }
    // Stores a list of connection session.
    public List<ConnectionSession> Sessions { get; set; }

    /// <summary>
    /// Gets the session length in ticks.
    /// </summary>
    /// <returns></returns>
    public long GetSessionLengthInTicks()
    {
        long totalSession = 0;
        foreach (var session in Sessions)
        {
            if (session.DisconnectedTime != 0)
            {
                totalSession += session.DisconnectedTime - session.ConnectedTime;
            }
            else
            {
                totalSession += DateTime.Now.Ticks - session.ConnectedTime;
            }
        }
        return totalSession;
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="UserCredential"/> class.
    /// </summary>
    public UserCredential()
    {
        Sessions = new List<ConnectionSession>();
    }
}

上面,咱們定義了ConnectionSession類,它用於存儲SignalR的ConnectionId、鏈接時間以及斷開鏈接時間,該對象充當着SignalR和實際的用戶鏈接之間溝通橋樑。

UserCredential用來保存全部用戶的會話信息。

接下來,讓咱們定義GameDetails,它用於保存當前遊戲的狀態和用戶等信息。

/// <summary>
/// The game details model.
/// </summary>
public class GameDetails
{
    public Guid GameId { get; set; }
    public int[,] GameMatrix { get; set; }
    public string NextTurn { get; set; }
    public string Message { get; set; }
    public Status GameStatus { get; set; }
    public UserCredential User1Id { get; set; }
    public UserCredential User2Id { get; set; }

    /// <summary>
    /// Initializes a new instance of the <see cref="GameDetails"/> class.
    /// </summary>
    public GameDetails()
    {
        GameMatrix = new int[3,3];
    }

    /// <summary>
    /// Checks the game status.
    /// </summary>
    /// <returns></returns>
    private void CheckGameStatus()
    {
        string status = CheckRows();
        if (string.IsNullOrEmpty(status))
        {
            status = CheckCols();
        }
        if (string.IsNullOrEmpty(status))
        {
            status = CheckDiagonal();
        }
        Message = !string.IsNullOrEmpty(status) ? status + " wins!" : string.Empty;
        if (string.IsNullOrEmpty(status))
        {
            status = CheckDraw();
            Message = status;
        }
    }

    /// <summary>
    /// Checks the game is draw or not.
    /// </summary>
    /// <returns></returns>
    private string CheckDraw()
    {
        bool isDefault = false;
        for (int row = 0; row < 3; row++)
        {
            for (int col = 0; col < 3; col++)
            {
                if (GameMatrix[row, col] == default(int))
                {
                    isDefault = true;
                    GameStatus = Status.Progress;
                    break;
                }
            }
            if (isDefault)
            {
                break;
            }
        }
        if (!isDefault)
        {
            GameStatus = Status.Draw;
        }
        return isDefault ? "In Progress" : "Game Drawn";
    }

    /// <summary>
    /// Sets the player move step.
    /// </summary>
    /// <param name="rowCol">The board cell</param>
    /// <param name="currentPlayerId">The current player identifier.</param>
    /// <returns>The step mark</returns>
    public string SetPlayerMove(dynamic rowCol, string currentPlayerId)
    {
        int x = int.Parse(rowCol.row.ToString());
        int y = int.Parse(rowCol.col.ToString());
        string returnString = string.Empty;

        if (!string.IsNullOrEmpty(currentPlayerId) &&
            GameMatrix[x - 1, y - 1] == default(int))
        {
            if (currentPlayerId == User1Id.UserId)
            {
                returnString = "O";
                GameMatrix[x - 1, y - 1] = 1;
                NextTurn = User2Id.UserId;
            }
            else
            {
                returnString = "X";
                GameMatrix[x - 1, y - 1] = 10;
                NextTurn = User1Id.UserId;
            }
        }
        CheckGameStatus();
        return returnString;
    }

    /// <summary>
    /// Checks the game status rows.
    /// </summary>
    /// <returns></returns>
    protected string CheckRows()
    {
        for (int r = 0; r < 3; r++)
        {
            int value = 0;
            for (int c = 0; c < 3; c++)
            {
                value += GameMatrix[r, c];
            }
            if (3 == value)
            {
                GameStatus = Status.Result;
                return User1Id.UserId;
            }
            else if (30 == value)
            {
                GameStatus = Status.Result;
                return User2Id.UserId;
            }
        }
        return string.Empty;
    }

    /// <summary>
    /// Checks the game status with cols.
    /// </summary>
    /// <returns></returns>
    protected string CheckCols()
    {
        for (int c = 0; c < 3; c++)
        {
            int value = 0;
            for (int r = 0; r < 3; r++)
            {
                value += GameMatrix[r, c];
            }
            if (3 == value)
            {
                GameStatus = Status.Result;
                return User1Id.UserId;
            }
            else if (30 == value)
            {
                GameStatus = Status.Result;
                return User2Id.UserId;
            }
        }
        return string.Empty;
    }

    /// <summary>
    /// Checks the game status in diagonal direction.
    /// </summary>
    /// <returns></returns>
    protected string CheckDiagonal()
    {
        int diagValueF = 0;
        int diagValueB = 0;
        for (int positonF = 0, positonB = 2; positonF < 3; positonF++, positonB--)
        {
            diagValueF += GameMatrix[positonF, positonF];
            diagValueB += GameMatrix[positonF, positonB];
        }
        if (diagValueF == 3)
        {
            GameStatus = Status.Result;
            return User1Id.UserId;
        }
        else if (diagValueF == 30)
        {
            GameStatus = Status.Result;
            return User2Id.UserId;
        }
        if (diagValueB == 3)
        {
            GameStatus = Status.Result;
            return User1Id.UserId;
        }
        else if (diagValueB == 30)
        {
            GameStatus = Status.Result;
            return User2Id.UserId;
        }
        return string.Empty;
    }
}

/// <summary>
/// The game status.
/// </summary>
public enum Status
{
    Progress = 0,
    Result,
    Draw
}

上面,咱們定義了井字遊戲對象類GameDetails,它保存遊戲用戶信息,以及包含判斷遊戲狀態的邏輯,當遊戲開始時Manager會建立一個遊戲實例,用來保存遊戲雙方和遊戲的信息。

在咱們平常的工做中常常須要在應用程序中保持一個惟一的實例,如:IO處理,數據庫操做等,因爲這些對象都要佔用重要的系統資源,因此咱們必須限制這些實例的建立或始終使用一個公用的實例,這就是咱們今天要介紹的——單例模式(Singleton),因此咱們把Manager定義爲單例類,它包含字段_connections和_games用於保存SignalR的連接和遊戲對象到緩存中,具體定義以下:

    /// <summary>
    ///  A manager of games (actions to create games) 
    /// </summary>
    public class Manager
    {
        // The single object.
        private static readonly Manager _instance = new Manager();
        private Dictionary<string, UserCredential> _connections;
        private Dictionary<Guid, GameDetails> _games;

        /// <summary>
        /// Prevents a default instance of the class from being created.
        /// </summary>
        static Manager()
        {
        }

        /// <summary>
        /// Prevents a default instance of the <see cref="Manager"/> class from being created.
        /// </summary>
        private Manager()
        {
            _connections = new Dictionary<string, UserCredential>();
            _games = new Dictionary<Guid, GameDetails>();
        }

        public static Manager Instance
        {
            get { return _instance; }
        }

        /// <summary>
        /// When the challenge started, create a game instance.
        /// </summary>
        /// <param name="gameId">The game identifier.</param>
        /// <returns>a game instance</returns>
        public GameDetails Game(Guid gameId)
        {
            if (!_games.ContainsKey(gameId))
            {
                _games[gameId] = new GameDetails { GameId = gameId };
            }
            return _games[gameId];
        }

        /// <summary>
        /// Gets all users in the connection.
        /// </summary>
        /// <returns></returns>
        public object AllUsers()
        {
            var u = _connections.Values.Select(s => new
            {
                UserId = s.UserId,
                ConnectionStatus = (int)s.ConnectionStatus,
                ConnectionId = s.Sessions[s.Sessions.Count - 1].ConnectionId
            });
            return u;
        }

        /// <summary>
        /// Creates the new user session.
        /// </summary>
        /// <param name="userId">The user identifier.</param>
        /// <param name="connectionId">The connection identifier.</param>
        private void CreateNewUserSession(string userId, string connectionId)
        {
            UserCredential curCred = new UserCredential
                {
                    ConnectionStatus = ConnectionStatus.Connected,
                    UserId = userId
                };

            curCred.Sessions.Add(new ConnectionSession
                {
                    ConnectionId = connectionId,
                    ConnectedTime = DateTime.Now.Ticks,
                    DisconnectedTime = 0L
                });

            _connections.Add(userId, curCred);

        }


        /// <summary>
        /// Updates the user session.
        /// </summary>
        /// <param name="userId">The user identifier.</param>
        /// <param name="connectionId">The connection identifier.</param>
        /// <param name="status">The status.</param>
        private void UpdateUserSession(string userId, string connectionId, ConnectionStatus status)
        {
            UserCredential curCred = _connections[userId];
            ExpireSession(curCred);
            curCred.Sessions.Add(new ConnectionSession
            {
                // The connection ID of the calling client.
                ConnectionId = connectionId,
                ConnectedTime = DateTime.Now.Ticks,
                DisconnectedTime = 0L
            });
            curCred.ConnectionStatus = status;
        }

        /// <summary>
        /// Expires the session.
        /// </summary>
        /// <param name="curCred">The current cred.</param>
        private static void ExpireSession(UserCredential curCred)
        {
            var curSession = curCred.Sessions.Find
                (s => s.DisconnectedTime == 0);

            if (curSession != null)
            {
                curSession.DisconnectedTime = DateTime.Now.Ticks;
            }
        }

        /// <summary>
        /// Updates the cache.
        /// </summary>
        /// <param name="userId">The user identifier.</param>
        /// <param name="connectionId">The connection identifier.</param>
        /// <param name="status">The status.</param>
        /// <returns></returns>
        internal GameDetails UpdateCache(string userId, string connectionId, ConnectionStatus status)
        {
            if (!string.IsNullOrWhiteSpace(userId) && _connections.ContainsKey(userId))
            {
                UpdateUserSession(userId, connectionId, status);
            }
            else
            {
                CreateNewUserSession(userId, connectionId);
            }

            var gd = _games.Values.LastOrDefault<GameDetails>(g => g.User1Id.UserId == userId || g.User2Id.UserId == userId);
            return gd;
        }

        /// <summary>
        /// Disconnects the specified connection identifier.
        /// </summary>
        /// <param name="connectionId">The connection identifier.</param>
        internal void Disconnect(string connectionId)
        {
            ConnectionSession session = null;

            if (_connections.Values.Count > 0)
            {
                foreach (var userCredential in _connections.Values)
                {
                    session = userCredential.Sessions.Find(s => s.ConnectionId == connectionId);
                    if (session != null)
                    {
                        session.DisconnectedTime = DateTime.Now.Ticks;
                        break;
                    }
                }
            }
        }

        internal void Logout(string userId)
        {
            ExpireSession(_connections[userId]);

            // Removes the connection.
            _connections.Remove(userId);
        }

        /// <summary>
        /// News the game.
        /// </summary>
        /// <param name="playerAId">The player a identifier.</param>
        /// <param name="playerBId">The player b identifier.</param>
        /// <returns>The GameDetails object</returns>
        internal GameDetails NewGame(string playerAId, string playerBId)
        {
            // Gets the playerA user credential.
            var playerA = _connections.Values.FirstOrDefault<UserCredential>
                (c => c.Sessions.FirstOrDefault<ConnectionSession>
                    (s => s.ConnectionId == playerAId) != null);

            // Gets the playerB user credential.
            var playerB = _connections.Values.FirstOrDefault<UserCredential>
                (c => c.Sessions.FirstOrDefault<ConnectionSession>
                    (s => s.ConnectionId == playerBId) != null);

            // When the game started, created a game instance.
            var newGame = new GameDetails
                {
                    GameId = Guid.NewGuid(),
                    User1Id = playerA,
                    User2Id = playerB,
                    NextTurn = playerA.UserId
                };

            // Stores the game instance into cache.
            _games[newGame.GameId] = newGame;
            return newGame;
        }
    }

上面,咱們在服務器端中定義了的對象模型和方法,接下來,咱們要公開這些方法讓客戶端瀏覽器調用。

SignalR的Hub模式

SignalR內部有兩類對象:

Persistent Connection(HTTP持久連接):持久性鏈接,用來解決長時間鏈接的能力,並且還能夠由客戶端主動向服務器要求數據,而服務器端也不須要實現太多細節,只須要處理PersistentConnection內所提供的五個事件:OnConnected、OnReconnected, OnReceived、OnError和OnDisconnect便可。

Hub:信息交換器,用來解決realtime信息交換的功能,服務器端能夠利用URL來註冊一個或多個Hub,只要鏈接到這個Hub,就能與全部的客戶端共享發送到服務器上的信息,同時服務器端能夠調用客戶端的腳本,不過它背後仍是不離HTTP的標準,因此它看起來神奇,但它並無那麼神奇,只是JavaScript更強,強到能夠用像eval()或是動態解釋執行的方式,容許JavaScript可以動態的加載與執行方法調用而己。

因爲,咱們要經過服務端調用客戶端瀏覽器,因此咱們使用Hub方式創建服務器和客戶端瀏覽器的連接,咱們在文件夾SignalrHubs中建立類GameNotificationHub,它繼承了Hub類而且實現方法OnConnected()、OnDisconnected()和OnReconnected(),具體定義以下:

// specifies the hub name for client to use.
[HubName("gameNotificationHub")]
[Authorize]
public class GameNotificationHub : Hub
{
    /// <summary>
    /// Challenges the specified connection identifier.
    /// </summary>
    /// <param name="connectionId">The connection identifier.</param>
    /// <param name="userId">The user identifier.</param>
    public void Challenge(string connectionId, string userId)
    {
        // Calls the specified client by connectionId.
        this.Clients.Client(connectionId).getChallengeResponse(Context.ConnectionId, userId);
        // The calling client wait user response.
        this.Clients.Caller.waitForResponse(userId);
    }

    /// <summary>
    /// Acceptes the challenge.
    /// </summary>
    /// <param name="connectionId">The connection identifier.</param>
    public void ChallengeAccepted(string connectionId)
    {
        // Creates a game instance.
        var details = Manager.Instance.NewGame(Context.ConnectionId, connectionId);
        // Adds the part a and b in the same group by game id.
        this.Groups.Add(Context.ConnectionId, details.GameId.ToString());
        this.Groups.Add(connectionId, details.GameId.ToString());
        // Starts the game between connection client.
        this.Clients.All.beginGame(details);
    }

    /// <summary>
    /// Refuses the challenge.
    /// </summary>
    /// <param name="connectionId">The connection identifier.</param>
    public void ChallengeRefused(string connectionId)
    {
        // Refuses the challenge by connectionId.
        this.Clients.Client(connectionId).challengeRefused();
    }

    /// <summary>
    /// Games the move.
    /// </summary>
    /// <param name="gameGuid">The game unique identifier.</param>
    /// <param name="rowCol">The row col.</param>
    public void GameMove(string gameGuid, dynamic rowCol)
    {
        var game = Manager.Instance.Game(new Guid(gameGuid));
        if (game != null)
        {
            string result = game.SetPlayerMove(rowCol, Context.User.Identity.Name);
            if (!string.IsNullOrEmpty(result))
            {
                // Calls group to draw the user step.
                this.Clients.Group(game.GameId.ToString()).drawPlay(rowCol, game, result);
            }
        }
    }

    /// <summary>
    /// Creates a connection.
    /// </summary>
    /// <returns>
    /// A <see cref="T:System.Threading.Tasks.Task" />
    /// </returns>
    public override System.Threading.Tasks.Task OnConnected()
    {
        string connectionId = Context.ConnectionId;
        string connectionName = string.Empty;

        GameDetails gd = null;

        if (Context.User != null && Context.User.Identity.IsAuthenticated)
        {
            // Retrieves user session in the cache.
            // If not found, create a new one.
            gd = Manager.Instance.UpdateCache(
                Context.User.Identity.Name,
                Context.ConnectionId,
                ConnectionStatus.Connected);
            connectionName = Context.User.Identity.Name;
        }
        if (gd != null && gd.GameStatus == Status.Progress)
        {
            // Creates a group.
            this.Groups.Add(connectionId, gd.GameId.ToString());
            //// No need to update the client by specified id.
            ////this.Clients.Client(connectionId).rejoinGame(Manager.Instance.AllUsers(), connectionName, gd);
            this.Clients.Group(gd.GameId.ToString()).rejoinGame(Manager.Instance.AllUsers(), connectionName, gd);
        }
        else
        {
            // Update the user list in the client.
            this.Clients.Caller.updateSelf(Manager.Instance.AllUsers(), connectionName);
        }
        this.Clients.Others.joined(
            new
                {
                    UserId = connectionName,
                    ConnectionStatus = (int)ConnectionStatus.Connected,
                    ConnectionId = connectionId
                },
                DateTime.Now.ToString());
        return base.OnConnected();
    }

    public override System.Threading.Tasks.Task OnDisconnected()
    {
        Manager.Instance.Disconnect(Context.ConnectionId);
        return Clients.All.leave(Context.ConnectionId,
            DateTime.Now.ToString());
    }

    public override System.Threading.Tasks.Task OnReconnected()
    {
        string connectionName = string.Empty;
        if (!string.IsNullOrEmpty(Context.User.Identity.Name))
        {
            Manager.Instance.UpdateCache(
                Context.User.Identity.Name,
                Context.ConnectionId,
                ConnectionStatus.Connected);
            connectionName = Context.User.Identity.Name;
        }
        return Clients.All.rejoined(connectionName);
    }
}

咱們看到GameNotificationHub繼承與Hub類,而且咱們定義了擴展方法Challenge()、ChallengeAccepted()、ChallengeRefused()和GameMove()。

  • Challenge:客戶端瀏覽器經過調用該方法向其餘用戶發出遊戲請求。
  • ChallengeAccepted:被請求用戶接受遊戲請求而且建立遊戲對象。
  • ChallengeRefused:被請求用戶拒絕遊戲請求。
  • GameMove:當用戶點擊canvas中的格時,向遊戲雙方發送canvas更新操做。

還有咱們給GameNotificationHub類添加了HubName屬性,這樣客戶端瀏覽器只能經過HubName訪問到服務器端的方法;若是沒有指定HubName屬性,那麼默認經過類名調用服務器端方法。

也許有人會問:「咱們是怎樣在服務器端(C#)調用Javascript的方法」。

這是因爲在服務器端聲明的全部hub的信息,通常都會生成JavaScript輸出到客戶端,.NET則是依賴Proxy來生成代理對象,這點就和WCF/.NET Remoting十分相似,而Proxy的內部則是將JSON轉換成對象,以讓客戶端能夠看到對象。

Javascript客戶端

如今,咱們已經完成了服務端的功能了,接下來,咱們將Knockout JS實現客戶端功能,咱們建立tictactor-signalr.js文件,具體定義以下:

// The game viem model.
var GameViewModel = function () {
    var self = this;
    
    // The connection user information.
    self.Users = ko.observableArray();
    
    // The user connection.
    self.UserConnections = [];
    
    // Stores the game instances.
    self.Game = {};
    
    // Gets the current user.
    self.CurrentPlayer = ko.observable('Game not started');
    
    // If the game started, Challenge is disabled.
    self.ChallengeDisabled = ko.observable(false);
};

上面,咱們定義了game的ViewModel類型,而且在其中定義了一系列屬性和方法。

其中,ChallengeDisabled()方法,判斷遊戲是否開始,遊戲已經開始了就再也不接受其餘用戶的遊戲請求,反之,還能夠接受用戶的遊戲請求。

接下來,咱們將實現客戶端Javascript調用服務器端的方法,具體定義以下:

$(function () {
    
    // Create a game view model.
    var vm = new GameViewModel();
    ko.applyBindings(vm);
   
    var $canvas = document.getElementById('gameCanvas'); //$('gameCanvas')[0];
    
    if ($canvas) {
        var hSpacing = $canvas.width / 3,
            vSpacing = $canvas.height / 3;
    }

    // Declares a proxy to reference the server hub. 
    // The connection name is the same as our declared in server side.
    var hub = $.connection.gameNotificationHub;

    // Draws the game with 'X' or 'O'.
    hub.client.drawPlay = function (rowCol, game, letter) {
        vm.Game = game;
        var row = rowCol.row,
            col = rowCol.col,
            hCenter = (col - 1) * hSpacing + (hSpacing / 2),
            vCenter = (row - 1) * vSpacing + (vSpacing / 2);
        writeMessage($canvas, letter, hCenter, vCenter);
        if (game.GameStatus == 0) {
            vm.CurrentPlayer(game.NextTurn);
        } else {
            vm.CurrentPlayer(game.Message);
            alert("Game Over - " + game.Message);
            location.reload();
        }
    };

    // Adds the online user.
    hub.client.joined = function (connection) {
        
        // Remove the connection by userid.
        vm.Users.remove(function(item) {
            return item.UserId == connection.UserId;
        });
        vm.Users.push(connection);  
    };

    // Gets the challenge response.
    hub.client.getChallengeResponse = function (connectionId, userId) {
        
        vm.ChallengeDisabled(true);
        refreshConnections();
        var cnf = confirm('You have been challenged to a game of Tic-Tac-ToR by \'' + userId + '\'. Ok to Accept!');

        if (cnf) {
            hub.server.challengeAccepted(connectionId);
        } else {
            hub.server.challengeRefused(connectionId);
            vm.ChallengeDisabled(false);
            refreshConnections();
        }

    };

    // Refreshs the user connection.
    function refreshConnections() {
        var oldItems = vm.Users.removeAll();
        vm.Users(oldItems);
    }


    // Stores all connection into the user list, expect the current login user.
    hub.client.updateSelf = function (connections, connectionName) {
        for (var i = 0; i < connections.length; i++) {
            if (connections[i].UserId != connectionName) {
                vm.Users.push(connections[i]);
            }
        } 
    };

    // Handles other client refuses the chanllenge.
    hub.client.challengeRefused = function () {
        vm.ChallengeDisabled(false);
        vm.CurrentPlayer('Challenge not accepted!');
        refreshConnections();
    };

    hub.client.waitForResponse = function (userId) {
        vm.ChallengeDisabled(true);
        vm.CurrentPlayer('Waiting for ' + userId + ' to accept challenge');
        refreshConnections();
    };

    // Keeps the connection still alive.
    hub.client.rejoinGame = function (connections, connectionName, gameDetails) {
        if (gameDetails != null) {
            vm.ChallengeDisabled(true);
            refreshConnections();
            vm.Game = gameDetails;
            
            // Sets the current player.
            vm.CurrentPlayer(gameDetails.NextTurn);
            
            for (var row = 0; row < 3; row++)
                for (var col = 0; col < 3; col++) {
                    var letter = '';
                    if (gameDetails.GameMatrix[row][col] == 1) {
                        letter = 'O';
                    }
                    else if (gameDetails.GameMatrix[row][col] == 10) {
                        letter = 'X';
                    }
                    if (letter != '') {
                        var hCenter = (col) * hSpacing + (hSpacing / 2);
                        var vCenter = (row) * vSpacing + (vSpacing / 2);
                        writeMessage($canvas, letter, hCenter, vCenter);
                    }
                }

            vm.Users = ko.observableArray();
            for (var i = 0; i < connections.length; i++) {
                if (connections[i].UserId != connectionName) {
                    vm.Users.push(connections[i]);
                }
            }
            vm.Users.remove(function (item) { return item.UserId == gameDetails.User1Id.UserId; });
            vm.Users.remove(function (item) { return item.UserId == gameDetails.User2Id.UserId; });
            
        }
    };

    // The game begins.
    hub.client.beginGame = function (gameDetails) {
        vm.ChallengeDisabled(true);
        refreshConnections();
        if (gameDetails.User1Id.UserId == clientId ||
            gameDetails.User2Id.UserId == clientId) {
            clearCanvas();
            vm.Game = gameDetails;
            vm.CurrentPlayer(gameDetails.NextTurn);
        }
        var oldArray = vm.Users;
        vm.Users.remove(function (item) { return item.UserId == gameDetails.User1Id.UserId; });
        vm.Users.remove(function (item) { return item.UserId == gameDetails.User2Id.UserId; });
    };

    // Removes the leave user from the user list.
    hub.client.leave = function (connectionId) {
        vm.Users.remove(function (item) { return item.ConnectionId == connectionId; });
    };

    $.connection.hub.start().done(function () {
        var canvasContext;
        $('#activeUsersList').delegate('.challenger', 'click', function () {
            vm.ChallengeDisabled(true);
            
            // TODO:
            var challengeTo = ko.dataFor(this);
            vm.CurrentPlayer('Waiting for ' + challengeTo.UserId + ' to accept challenge');
            hub.server.challenge(challengeTo.ConnectionId, clientId);
            refreshConnections();
        });

        if ($canvas && $canvas.getContext) {
            canvasContext = $canvas.getContext('2d');
            var rect = $canvas.getBoundingClientRect();
            $canvas.height = rect.height;
            $canvas.width = rect.width;
            hSpacing = $canvas.width / 3;
            vSpacing = $canvas.height / 3;

            // canvas click event handle.
            $canvas.addEventListener('click', function (evt) {
                if (vm.CurrentPlayer() == clientId) {
                    var rowCol = getRowCol(evt);
                    rowCol.Player = 'O';
                    hub.server.gameMove(vm.Game.GameId, rowCol);
                }
            }, false);

            drawGrid(canvasContext);
        }
        
        // Gets the user clicks on grid row and column position.
        function getRowCol(evt) {
            var hSpacing = $canvas.width / 3;
            var vSpacing = $canvas.height / 3;
            var mousePos = getMousePos($canvas, evt);
            return {
                row: Math.ceil(mousePos.y / vSpacing),
                col: Math.ceil(mousePos.x / hSpacing)
            };
        }

        // Gets the user mouse click relative poisition in the canvas. 
        function getMousePos($canvas, evt) {
            var rect = $canvas.getBoundingClientRect();
            return {
                x: evt.clientX - rect.left,
                y: evt.clientY - rect.top
            };
        }
    });
    
    // When the game end, clear the canvas.
    function clearCanvas() {
        if ($canvas && $canvas.getContext) {
            var canvasContext = $canvas.getContext('2d');
            var rect = $canvas.getBoundingClientRect();
            $canvas.height = rect.height;
            $canvas.width = rect.width;

            if (canvasContext) {
                canvasContext.clearRect(rect.left, rect.top, rect.width, rect.height);
            }
            drawGrid(canvasContext);
        }
    }

    // Draws the grid.
    function drawGrid(canvasContext) {
        var hSpacing = $canvas.width / 3;
        var vSpacing = $canvas.height / 3;
        canvasContext.lineWidth = "2.0";
        for (var i = 1; i < 3; i++) {
            canvasContext.beginPath();
            canvasContext.moveTo(0, vSpacing * i);
            canvasContext.lineTo($canvas.width, vSpacing * i);
            canvasContext.stroke();

            canvasContext.beginPath();
            canvasContext.moveTo(hSpacing * i, 0);
            canvasContext.lineTo(hSpacing * i, $canvas.height);
            canvasContext.stroke();
        }
    }

    // Update the grid with 'X' or 'O'.
    function writeMessage($canvas, message, x, y) {
        var canvasContext = $canvas.getContext('2d');
        canvasContext.font = '40pt Calibri';
        canvasContext.fillStyle = 'red';
        var textSize = canvasContext.measureText(message);
        canvasContext.fillText(message, x - (textSize.width / 2), y + 10);

    }
});


drawPlay:每當用戶點擊單元格時,繪製相應的標記‘X’或‘O’。

  • joined:獲取在線用戶,保存到User屬性中。
  • getChallengeResponse:獲取用戶是否接受挑戰請求。
  • updateSelf:更新User屬性中的在線用戶。
  • challengeRefused:拒絕遊戲請求。
  • waitForResponse:等待用戶回覆請求。
  • rejoinGame:保存用戶遊戲鏈接狀態。
  • getRowCol:獲取用戶點擊的單元格位置。
  • getMousePos:獲取用戶鼠標點擊相對於Canvas的座標。

這裏咱們實現了SignalR JS的done()方法,它用戶檢測SignalR JS是否加載完畢這相對於jQuery的$.ready()。

咱們在裏面定義了用戶列表和canvas的事件處理方法;當SignalR JS加載完畢後,調用drawGrid()方法繪製井字遊戲。

頁面設計

如今,咱們已經實現了客戶端的經過SignalR和Knockout JS調用服務端的方法,接着咱們須要把數據綁定到頁面中,首先咱們在View中建立Index.cshtml頁面,具體定義以下:

<div class="container">
    <div class="content">
        @{
            if (Request.IsAuthenticated)
            {
                <!-- Game board START -->
                <div class="game-container">
                    <div id="grid" style="height: 400px">
                        <canvas id="gameCanvas" style="width: 100%; height: 100%"></canvas>
                    </div>
                </div>
                <!-- Game board END -->
            
                <!-- User list START -->
                <div class="game-player-container">
                    <div class="game-player-header">Online Users</div>
                    <div>
                        <ul id="activeUsersList" class="game-player-list" data-bind="foreach: Users">
                            <li class="game-list-item">
                                <div style="height: 30px">
                                    <div style="float: left; padding-top: 5px">
                                        <span data-bind="text: UserId"></span>
                                    </div>
                                    <div class="game-list-item-button">
                                        <div>
                                            <button data-bind="attr: { disabled: $parent.ChallengeDisabled() }" 
                                                    class="challenger game-list-button">Challenge</button>
                                        </div>
                                    </div>
                                    <input type="hidden" data-bind="value: ConnectionId"/>
                                </div>
                            </li>
                        </ul>
                    </div>
                </div>
                <!-- User list END -->
            
                <div style="width: 100%; text-align: center; font-size: 20px";>
                    Next Turn: <label data-bind="text: CurrentPlayer()"></label>
                </div>
            }
            else
            {
                        <div class="login-placeholder">
            <div id="gridNoLogin" style="height: 400px; text-align: center">
                <h1><a href="@Url.Action("Login", "Account")">Login</a>
                </h1>
            </div>

        </div>
            }
        }
    </div>
</div>

咱們在Index頁面中定義了三個元素,第一個game-container井字遊戲的區域,第二個game-player-container當前在線用戶列表,第三個當前遊戲操做用戶。

 signarl14

圖9 井字遊戲

如今,咱們已經基本實現了井字遊戲了,用戶登錄後就能夠查看到當前在線的用戶,而後點擊該用戶就能夠發起遊戲請求了,若是其餘用戶接受遊戲請求就能夠開始遊戲了。

SignalR代理方法

前面,咱們說到SignalR在服務器端聲明的全部hub的信息,都會通常生成 JavaScript 輸出到客戶端。

如今,咱們查看客戶端Javascript文件發現多了一個文件signalr,而且生存了一個hubs.js文件,它裏面包含了對應於服務端的方法,這樣客戶端瀏覽器就能夠經過這些Proxy方法調用咱們服務器端中的方法了。

signarl17

圖10 SignalR代理對象

 

$.hubConnection.prototype.createHubProxies = function () {
    var proxies = {};
    this.starting(function () {
        // Register the hub proxies as subscribed
        // (instance, shouldSubscribe)
        registerHubProxies(proxies, true);

        this._registerSubscribedHubs();
    }).disconnected(function () {
        // Unsubscribe all hub proxies when we "disconnect".  This is to ensure that we do not re-add functional call backs.
        // (instance, shouldSubscribe)
        registerHubProxies(proxies, false);
    });

    proxies.gameNotificationHub = this.createHubProxy('gameNotificationHub'); 
    proxies.gameNotificationHub.client = { };
    proxies.gameNotificationHub.server = {
        challenge: function (connectionId, userId) {
            return proxies.gameNotificationHub.invoke.apply(proxies.gameNotificationHub, $.merge(["Challenge"], $.makeArray(arguments)));
         },

        challengeAccepted: function (connectionId) {
            return proxies.gameNotificationHub.invoke.apply(proxies.gameNotificationHub, $.merge(["ChallengeAccepted"], $.makeArray(arguments)));
         },

        challengeRefused: function (connectionId) {
            return proxies.gameNotificationHub.invoke.apply(proxies.gameNotificationHub, $.merge(["ChallengeRefused"], $.makeArray(arguments)));
         },

        gameMove: function (gameGuid, rowCol) {
            return proxies.gameNotificationHub.invoke.apply(proxies.gameNotificationHub, $.merge(["GameMove"], $.makeArray(arguments)));
         }
    };

    return proxies;
};

CSS樣式

咱們使用bootstrap樣式,在文件夾Content中添加bootstrap-responsive.css和bootstrap.css文件。

而後,在BundleConfig中添加css文件引用,具體定義以下:

            bundles.Add(new StyleBundle("~/Styles/bootstrap/css").Include(
                "~/Content/bootstrap-responsive.css",
                "~/Content/bootstrap.css"));

 

  signarl15

signarl16

圖10 井字遊戲

1.1.3 總結

本文咱們經過實現一個實時的井字遊戲,介紹了經過ASP.NET MVC構建服務器端,而且提供遊戲接口讓客戶端瀏覽器調用;而後,經過SignalR的Hub方式確保客戶端和服務端連接的有效性;最後咱們經過KnockoutJS實現頁面的實時更新。

Demo

參考

[1] http://www.cnblogs.com/shanyou/archive/2012/07/28/2613693.html

[2] http://www.asp.net/signalr/overview/signalr-20/getting-started-with-signalr-20/introduction-to-signalr

[3] http://blogs.msdn.com/b/scott_hanselman/archive/2011/11/10/signalr.aspx

[4] http://www.asp.net/signalr/overview/signalr-20/getting-started-with-signalr-20/tutorial-getting-started-with-signalr-20-and-mvc-5

[5] http://www.cnblogs.com/rush/p/3574429.html

[6] https://github.com/dotnetcurry/signalr-tictactoe-dncmag5

相關文章
相關標籤/搜索