Làm game cờ vua online với Rails cable

Xin chào các bạn, trong bài viết lần này mình sẽ giới thiệu với các bạn demo của ứng dụng chơi cờ vua online sử dụng Rails 5 cable và thư viện chess.js. Bài viết được tổng hợp từ nhiều nguồn (bên dưới bài viết) và tự nghiên cứu của cá nhân mình. I. Tổng quan về Rails cable và game cờ vua Bản Rails 5 ra mắt lần này có bổ sung tính năng Web socket, đây là một tính năng khá hay và mạnh mẽ, hỗ trợ giao tiếp hai chiều giữa client và server bằng cách tạo 1 kết nối bằng giao thức TCP socket . Để học hỏi thêm về công nghệ này, mình chọn game cờ vua để viết ví dụ. Cờ vua là game cần 2 người chơi cho 1 trân đấu. 2 người chơi sẽ thi đấu với nhau trực tuyển qua mạng nên việc sử dụng websocket tạo một kênh kết nối giữa 2 người chơi là rất hợp lý. II. Viết demo game cờ vua Trước hết chúng ta cần khởi tạo một project Rails như sau:

rails new Rails5ChessDemo

Để tạo ra bàn cờ vua ở mỗi máy client của người chơi, mình sử dụng thư viện chessboard, download tại địa chỉ http://chessboardjs.com/. Sau khi download về đuợc file min.js, các bạn thêm thư viện vào project bằng cách thêm vào file application.js

//= chessboard.min

Sau đó, chúng ta thêm dòng sau vào file html để hiển thị bàn cờ vua trên view (trong dự án này ta sẽ thêm vào file app/views/welcome/index.html.erb.

<div id="board2" style="width: 400px"></div>
<input type="button" id="startBtn" value="Start" />
<input type="button" id="clearBtn" value="Clear" />

Ở file js, chúng ta thêm đoạn sau

var board2 = ChessBoard('board2', {
  draggable: true,
  dropOffBoard: 'trash',
  sparePieces: true
});
$('#startBtn').on('click', board2.start);
$('#clearBtn').on('click', board2.clear);

Đoạn code trên cho phép chúng ta có thể kéo thả các quân cờ đến vị trí bất kì, nút start bắt đầu chơi và nút clear để xóa các quân cờ. Tuy nhiên thư viện này chưa giúp chúng ta kiếm tra các nước đi của quân cờ hợp lệ hay thực hiện các thao tác như ăn quân đối phương và kiểm tra chiếu hết nước (game over). Để thực hiện được các việc trên, chúng ta cần thêm thư viện chess.js (https://github.com/jhlywa/chess.js).

Tương tự thư viện chessboard, các bạn có thể tải chess.js và thêm vào file application.js hoặc sử dụng cdn để thêm thư viện vào project của mình như sau

<script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.10.2/chess.js"></script>

Thư viện chess cung cấp cho chúng ta các hàm cần thiết để xử lí game cờ vua, điển hình chúng ta thuờng sử dụng 1 số hàm sau:

.game_over(): Hàm này sẽ trả về true khi game kết thúc (bao gồm bị chiếu hết, trận đấu hòa hoặc vào thế bí). .in_check(): Trả về trạng thái đang bị chiếu tuớng. .history([ options ]): Trả về danh sách lịch sử các nuớc đi, nếu truyền tham số vebose = true, chúng ta sẽ nhận đuợc danh sách lịch sử các nước đi, ví dụ như sau:

var chess = new Chess();
chess.move('e4');
chess.move('e5');
chess.move('f4');
chess.move('exf4');

chess.history();
// -> ['e4', 'e5', 'f4', 'exf4']

chess.history({ verbose: true });
// -> [{ color: 'w', from: 'e2', to: 'e4', flags: 'b', piece: 'p', san: 'e4' },
//     { color: 'b', from: 'e7', to: 'e5', flags: 'b', piece: 'p', san: 'e5' },
//     { color: 'w', from: 'f2', to: 'f4', flags: 'b', piece: 'p', san: 'f4' },
//     { color: 'b', from: 'e5', to: 'f4', flags: 'c', piece: 'p', captured: 'p', san: 'exf4' }]

Ngoài ra còn 1 số hàm khác tuy nhiên không quá quan trọng, các bạn có thể tìm hiểu thêm trên github của thư viện chess nhé.

Bây giờ chúng ta sẽ tạo routes và controller để người chơi có thể vào chơi cùng nhau. Tạo controller welcome

rails g controller welcome

Tạo router đến controller.

# config/routes.rb
Rails.application.routes.draw do
  root to: "welcome#index"
  mount ActionCable.server => "/cable"
end

File view hiển thị bàn cờ vua chúng ta đã tạo bên trên rồi nên bây giờ k cần nữa 😄.

II.Xác thực kết nối

Chúng ta có 2 người chơi cùng nhau trong 1 ván cờ, vậy chúng ta cần xác thực được ai là ai, khi người chơi A thực hiện 1 nước đi, chúng ta cũng cần gửi request thông báo cho user B cập nhật bàn cờ lại. Với 1 hệ thống bình thường chúng ta thường yêu cầu người chơi đăng nhập, từ đó dựa vào user id để xác thực nhưng với demo hiện tại, chúng ta sẽ cho phép người chơi vào chơi với nhau một cách hoàn toàn ngẫu nhiên mà không cần đăng nhập. Để làm được điều đó, mỗi khi người chơi connect vào action cable, chúng ta sinh một key ngẫu nhiên để xác thực người chơi, phần xác thực từng người chơi chúng ta sẽ làm sau.

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :uuid

    def connect
      self.uuid = SecureRandom.uuid
    end
  end
end

Chúng ta đã gán uuid bằng một chuỗi mã random, chưa lưu vào database nên chúng ta sẽ truy nhập uuid qua biến instance trong kênh này luôn. III. Tạo channel và subcription Channel là một khái niệm rất quan trọng khi chúng ta làm việc với action cable, channel có các phương thức để thêm và xóa các đối tuợng. Mỗi channel đuợc tạo ra, chúng ta có thể thêm các subcription vào, channel sẽ tạo ra 1 kết nối với client, khi có thay đổi server sẽ gửi message tới client để cập nhật. Trước hết chúng ta sẽ tạo ra 1 channel bằng câu lệnh:

rails g channel game

Câu lệnh trên sẽ sinh ra 2 file app/channels/game_channel.rb và app/assets/javascripts/channels/game.coffee. Sau đó chúng ta tạo ra kết nối giữa client và server với hàm subcribed. Chúng ta sử dụng biến instance uuid tạo ra bên trên tạo ra 1 subcription để giúp server và client kết nối và nhận ra nhau.

# app/channels/game_channel.rb
class GameChannel < ApplicationCable::Channel
  def subscribed
    stream_from "player_#{uuid}"
  end
end

Chúng ta test xem code họat động ổn định chưa bằng cách chạy rails server và xem log, như thế này là thành công rồi

Processing by WelcomeController#index as HTML
  Rendered welcome/index.html.erb within layouts/application (1.5ms)

Registered connection (43b6fc17d18a9bcd513230ea1da8b7c7)
GameChannel is transmitting the subscription confirmation
GameChannel is streaming from player_43b6fc17d18a9bcd513230ea1da8b7c7

Chúng ta đã tạo đuợc kết nối giữa server và máy client có hash uuid sinh ra là 43b6fc17d18a9bcd513230ea1da8b7c7.

IV. Tạo ra các cặp người chơi cùng nhau Mỗi khi có người chơi kết nối vào hệ thống, chúng ta sẽ kiểm tra xem có người chơi nào cũng đang chờ bắt cặp chơi cùng không, nếu có ta sẽ kết nối 2 người để chơi cùng nhau, nếu không ta sẽ lưu hash uuid lại để chờ kết nối với người chơi tiếp theo (gọi là seek). Chúng ta viết model seek như sau:

class Seek
  def self.create(uuid)
    if opponent = REDIS.spop("seeks")
      Game.start(uuid, opponent)
    else
      REDIS.sadd("seeks", uuid)
    end
  end

  def self.remove(uuid)
    REDIS.srem("seeks", uuid)
  end

  def self.clear_all
    REDIS.del("seeks")
  end
end

Lưu ý là chúng ta dùng redis để lưu key nên máy các bạn phải cài đặt redis nhé.

Mỗi khi có người dùng request tới hệ thống, chúng ta sẽ gọi hàm create uuid, sau đó lưu vào trong redis bằng hàm create trong model Seek như sau:

class GameChannel < ApplicationCable::Channel
  def subscribed
    stream_from "player_#{uuid}"
    Seek.create(uuid)
  end
end

Và tất nhiên khi người dùng thoát khỏi hệ thống, chúng ta cũng phải xóa key khỏi seek, nếu không sẽ có người chơi không may mắn phải bắt cặp chơi với một người chơi đã thoát khỏi game 😄. Khi client ngắt kết nối, chúng ta gọi hàm unsubcribed để remove key

  def unsubscribed
    Seek.remove(uuid)
  end

Ở phía client, khi mới kết nối vào hệ thống, chúng ta hiển thị thông báo đang tìm người chơi cùng

App.game = App.cable.subscriptions.create "GameChannel",
  connected: ->
    @printMessage("Waiting for opponent...")

  printMessage: (message) ->
    $("#messages").append("<p>#{message}</p>")

V. Start một game

Trong hàm create ở model Seek bên trên, chúng ta sẽ gọi hàm start game nếu có uuid trong REDIS. logic hàm start game như sau.

class Game
  def self.start(uuid1, uuid2)
    white, black = [uuid1, uuid2].shuffle

    ActionCable.server.broadcast "player_#{white}", {action: "game_start", msg: "white"}
    ActionCable.server.broadcast "player_#{black}", {action: "game_start", msg: "black"}

    REDIS.set("opponent_for:#{white}", black)
    REDIS.set("opponent_for:#{black}", white)
  end
end

uuid của 2 người chơi lấy ra từ redis được trộn ngẫu nhiên để gán cho việc cầm quân đen hay trắng. Mấu chốt của hàm trên là ActionCable.server.broadcast, hàm này sẽ gửi cho client một message thông báo bạn cầm quân đen hay quân trắng, và chúng ta cũng lưu vào REDIS màu quân của đối phương. Message tôi trả về ở đây chỉ đơn giản là "white" hoặc "black" nhưng message này có thể là 1 hash json hay một message để hiện thị lên màn hình tùy ý các bạn. Quan trọng là chúng ta cần lưu màu của đối phương vào redis để kiểm sóat trận đấu. Mỗi khi một bên thực hiện một nước đi, chúng ta dựa vào key này để xác định đối thủ và gửi message để thông báo nước đi. Giờ là xử lí dưới client, chúng ta sẽ thông báo game start và màu quân của client, code này sẽ được viết vào hàm received trong file game.coffe

  received: (data) ->
    switch data.action
      when "game_start"
        App.board.position("start")
        App.board.orientation(data.msg)
        @printMessage("Game started! You play as #{data.msg}.")

Hàm received là hàm callback được gọi mỗi khi nhận được message từ server, do đó đây là một hàm rất quan trọng. Nó sẽ có nhiệm vụ xử lý từ khi bắt đầu game tới lúc kết thúc. Khi một người chơi thực hiện 1 nước đi, client sẽ gửi nước đi đó lên server và server sẽ gửi tới client bên đối phương, đoạn code như sau

# app/assets/javascripts/board.coffee
$ ->
  App.chess = new Chess()

  cfg =
    onDrop: (source, target) =>
      move = App.chess.move
        from: source
        to: target
        promotion: "q"

      if (move == null)
        # illegal move
        return "snapback"
      else
        App.game.perform("make_move", move)

  App.board = ChessBoard("chessboard", cfg)

Hàm ondrop là hàm của thư viện chessboard mình đã giới thiệu bên trên, nó xác định đuợc vị trí mà chúng ta đặt quân cờ xuống, từ đó ta biết quân cờ đi từ đâu tới đâu. Hàm perform truyền tham số "make_move", vì vậy trên server chúng ta cũng cần tạo hàm action make_move để xử lí khi nhận đuợc request, action này sẽ gọi hàm move trong class game.

class GameChannel < ApplicationCable::Channel
  def make_move(data)
    Game.make_move(uuid, data)
  end
end
class Game
  def self.make_move(uuid, data)
    opponent = opponent_for(uuid)
    move_string = "#{data["from"]}-#{data["to"]}"

    ActionCable.server.broadcast "player_#{opponent}", {action: "make_move", msg: move_string}
  end
end

Chúng ta sẽ làm công việc đó là chuyển nước đi vừa rồi cho máy của đối phương, vậy là xong. Sau khi tiếp nhận message chứa nước đi, máy client sẽ cập nhật lại bàn cờ, chúng ta sẽ mở rộng hàm received bên trên

App.game = App.cable.subscriptions.create "GameChannel",
  # ...

  received: (data) ->
    switch data.action
      when "game_start"
        # ...
      when "make_move"
        [source, target] = data.msg.split("-")

        App.board.move(data.msg)
        App.chess.move
          from: source
          to: target
          promotion: "q"
          

Ta split data để lấy được from - to của máy đối phương, sau đó move quân cờ bằng hàm move của chessboard.

VI. Xử lí khi người chơi thóat khỏi game

Như đã nói ở trên, thư viện chess đã xử lí cho chúng ta khi cờ hòa hoặc 1 bên chiếu hết, còn trường hợp khi đối phương thoát khỏi mạng, chúng ta cần xử thắng cuộc cho người chơi còn lại. Vì vậy trong hàm unsubcribed chúng ta viết thêm dòng sau

  def unsubscribed
   Seek.remove(uuid)
   Game.forfeit(uuid)
 end

Và hàm forfeit trong class Game.

class Game
  def self.forfeit(uuid)
    if winner = opponent_for(uuid)
      ActionCable.server.broadcast "player_#{winner}", {action: "opponent_forfeits"}
    end
  end
end

Rất đơn giản, chúng ta tìm ra đối thủ của người chơi vừa unsubcribed, nếu có thì gửi message báo đối phương đã rời khỏi cuộc chơi và bạn chiến thắng 😄.

Phía client ta tiếp tục mở rộng hàm received

App.game = App.cable.subscriptions.create "GameChannel",
  # ...

  received: (data) ->
    switch data.action
      when "game_start"
        # ...
      when "make_move"
        # ...
      when "opponent_forfeits"
        @printMessage("Opponent forfeits. You win!")

Kết luận Demo trên mình đã làm theo và chơi được khá ổn, đây cũng là ví dụ rất tốt để học thêm về Action Cable trên Rails 5, tuy nhiên nó còn khá nhiều hạn chế mà chúng ta sẽ cải thiện trong tương lai

  • Chưa có đăng nhập xác thực người chơi, từ đó có đánh giá thắng thua và tìm người chơi phù hợp với nhau.
  • Tạo bản ghi trong db lưu lại lịch sử nước đi và các ván chơi (sử dụng hàm history mình đã nói bên trên).
  • Hạn chế thời gian cho 1 game.
  • Validate các nước đi trên server (hiện tại thư viện chess chỉ validate ở client).
  • Các tính năng lưu game và xem lại game. Các bạn nếu có hứng thú có thể nâng cấp thêm cho demo thành 1 sản phẩm hoàn chỉnh và để lại comment dưới bài viết. Cảm ơn các bạn đã theo dõi bài viết.

Tài liệu tham khảo: http://joeyschoblaska.com/posts/rails-5-chess-with-action-cable-websockets https://github.com/jhlywa/chess.js http://chessboardjs.com/


All Rights Reserved