Rails5のActionCableでイカゲームもどきを作ってみた


Rails5から追加されたActionCableを使って某人気イカゲームもどきを作ってみました。ActionCableを使えばかなり簡単にWebSocketのリアルタイムな機能が作れます。

## できあがりのイメージ

![ika2.gif](https://qiita-image-store.s3.amazonaws.com/0/2152/d893dab4-b9d6-cdba-216f-083700c3f66f.gif "ika2.gif")


サンプルのソースコードはGitHubに上がっているので参考にしてみてください。

- https://github.com/zaru/action_cable-ika-game-sample

## バトル画面の実装

とりあえずクライアント側もRailsでHTMLとして作るので、バトル用の画面を作ります。

```
$ rails g controller battle
```


### routes.rb

```ruby
root to: "battle#index"
```

### battle_controller.rb

```ruby
class BattleController < ApplicationController

  def index
  end

end
```


## ActionCableを設定・作成

ジェネレータが用意されているので `g channel` でチェンネルを作ります。こいつがActionCableのロジック本体になります。

```
$ rails g channel battle attack
```

### battle_channel.rb

```ruby
class BattleChannel < ApplicationCable::Channel
  def subscribed
    # stream_from "some_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def attack
  end
end
```

続いてActionCableをRailsサーバと一緒に使えるようにマウントしてあげます。実際、本番環境だと別プロセスでやるのが王道なのかな？

### routes.rb

```ruby
mount ActionCable.server => '/cable'
```

デフォルトだとActionCableは別ホストからは接続できないので、その制限を外してあげます。

### development.rb

```
config.action_cable.disable_request_forgery_protection = true
```

生成された `battle_channel.rb` をカスタマイズします。 `stream_from` でユーザが購読するチャンネル名を指定します。ここでは `battle_channel` としました。

`attack` メソッドでは、ブロードキャストで `battle_channel` 宛に `message: "hoge"` を送信しています。こうすることで、 `battle_channel` を購読している全てのユーザにメッセージを送ることが出来ます。


### battle_channel.rb

```ruby
class BattleChannel < ApplicationCable::Channel
  def subscribed
    stream_from "battle_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def attack
    ActionCable.server.broadcast "battle_channel", message: "hoge"
  end
end
```

WebSocketのサーバ側は上記でOKなので、次はクライアント側を確認します。クライアント側は、`assets/javascript` ディレクトリに `channel` が追加されています。中身は、WebSocketからのコールバック関数が定義されています。ここで接続時や切断時、データを受け取った時…などのアクションが組み込めます。

先程作った `attack` も追加されています。 `@perform` これを使うことで `battle_channel.rb` の `attack` メソッドを叩くことが出来ます。

デバッグしやすいように `received` に `console.log` を追加しておきます。

### javascript/channels/battle.coffe

```coffeescript
App.battle = App.cable.subscriptions.create "BattleChannel",
  connected: ->
    # Called when the subscription is ready for use on the server

  disconnected: ->
    # Called when the subscription has been terminated by the server

  received: (data) ->
    # Called when there's incoming data on the websocket for this channel
    console.log data

  attack: ->
    @perform 'attack'
```

まずはテストとして `attack` メソッドをブラウザから叩いてみます。App.battleはグローバル空間にあるので、簡単に叩けます。 `rails s -b 0.0.0.0` としてサーバを起動しておきます。せっかくなので、複数のブラウザを立ち上げておきます。

### Chrome / Safariの開発ツール

```javascript
App.battle.attack()
```

これで `message: "hoge"` を受け取れていることが確認できると思います。簡単ですね。基本はこれだけで、あとはゲームっぽい演出を加えていきます。

## クリック位置の座標をブロードキャスト

ゲームのやり方として、マウスなどでクリックした場所にインクを飛ばすようにしようと思います。なので、まずはクリック位置の座標を全員に送る必要があります。

通常の `battle.coffee` にクリックイベントを仕込みます。

### javascripts/battle.coffee

```
$ ->
  $(window).click (e)->
    position = { x: e.pageX, y: e.pageY }
    App.battle.attack(position)
```

クライアントから送られた座標を全員にブロードキャストします。

### channels/battle.coffee

```
App.battle = App.cable.subscriptions.create "BattleChannel",
  connected: ->
    # Called when the subscription is ready for use on the server

  disconnected: ->
    # Called when the subscription has been terminated by the server

  received: (data) ->
    console.log data

  attack: (position) ->
    @perform 'attack', position: position
```

### battle_channel.rb

```
  def attack(position)
    ActionCable.server.broadcast "battle_channel", battle: position
  end
```

## クリック位置にドットを表示するように実装

最後に受け取った座標を元に描画します。例だと、単なる1pxのドットなので全然見分けがつきませんが、これをインクの画像にすればOKです。

### channels/battle.coffee

```
  received: (data) ->
    console.log data
    attack_point = $('<div>')
    attack_point.css('position', 'absolute')
    attack_point.css('top', data.battle.position.y)
    attack_point.css('left', data.battle.position.x)
    attack_point.css('width', '1px')
    attack_point.css('height', '1px')
    attack_point.css('background', '#f00')
    $('body').append attack_point
```

以上で、イカゲームの基礎は出来上がったも同然です。簡単ですね。あと以下のような機能を実装すればよりゲームらしく感じると思います。GitHubにアップしているソースコードでは荒いですが実装しているので参考にしてみてください。

- マッチング
- タイム機能
- 勝敗判定

## 気になった・はまった所紹介

### マッチング

対戦させるためにマッチングが必要になりますが、こんかいは1つの固定の部屋で戦うようにしたのでChannelは1つです。ただ、普通は複数の部屋で戦うと思うので、ちょっとした工夫が必要になります。

例としてはこんな感じです。1対1を想定しています。まず、マッチング用のSeekモデルを作って、Redisのリストにユーザをぶち込みます。誰か既にいたらマッチング成功。ゲームスタート。そして、専用の部屋を作成し、RoomIDをそれぞれのChannelに通知して互いに対戦します。

```
class GameChannel < ApplicationCable::Channel
  def subscribed
    stream_from "game_player_#{current_user.id}"
    Seek.create(current_user.id)
  end
end
```

```
class Seek
  def self.create(user_id)
    if opponent = REDIS.spop("seeks")
      #TODO: ここでマッチング成立ゲーム開始
      Rails.logger.debug "マッチング成功: user_id: #{user_id} / opponent_id: #{opponent}"
      Game.start(user_id, opponent)
    else
      REDIS.sadd("seeks", user_id)
    end
  end

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

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

```
class Game
  def self.start(user_id1, user_id2)

    # ここで対戦用のChannelを保存、Channel名をそれぞれのプレイヤーに通知する
    room = Room.create(name: "room_#{user_id1}_#{user_id2}")

    ActionCable.server.broadcast "game_player_#{user_id1}", { action: "battle_start", room_id: "#{room.id}" }
    ActionCable.server.broadcast "game_player_#{user_id2}", { action: "battle_start", room_id: "#{room.id}" }
  end
end
```

### インクの描画

インクはSVG画像を使っています。色んな色を使いたかったので、CSSでSVGの色を塗りつぶして再利用することにしました。が、SVGファイルだと外部CSSが効かないというアレがあるので、Ruby側でインライン化したりしました…。地味に面倒。

```slim
.material_svgs
  - (1..12).each do |i|
    div id="ink-#{i}"
      == File.read(Rails.root.join("app/assets/images/#{i}.svg"))
```

### 勝敗判定

今回のイカゲームは、塗りつぶした領域が多いほうが勝ちになります。なので、ゲーム終了後にどの色が一番多いかを判定しなくちゃいけないんですが、実装の時間がなかったのでサーバ側でログ判定せずに、雑にクライアントのブラウザ側で画像を合成して、サーバに投げてImageMagickで解析しています。

- インクのSVGファイルを全て`<img>`タグに変換
- `<img>`を`<canvas>`に追加
- `<canvas>`をBASE64でサーバに投げる
- サーバはImageMagickでどの色が多いかを解析し、クライアントに返す
- 受け取ったクライアントはそれを表示

このロジック、けっこう穴があって、全ユーザが上記の処理を行うため、ブラウザの大きさに寄って勝敗が変わってきてしまいます。まぁそこらへんはご愛嬌ということで。あと、SVGでやっちゃったけど、最初から全部canvasでやれば負荷とかも軽くなったのかもしれない…。