Rails5から追加されたActionCableを使って某人気イカゲームもどきを作ってみました。ActionCableを使えばかなり簡単にWebSocketのリアルタイムな機能が作れます。
できあがりのイメージ
サンプルのソースコードはGitHubに上がっているので参考にしてみてください。
バトル画面の実装
とりあえずクライアント側もRailsでHTMLとして作るので、バトル用の画面を作ります。
$ rails g controller battle
routes.rb
root to: "battle#index"
battle_controller.rb
class BattleController < ApplicationController
def index
end
end
ActionCableを設定・作成
ジェネレータが用意されているので g channel
でチェンネルを作ります。こいつがActionCableのロジック本体になります。
$ rails g channel battle attack
battle_channel.rb
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
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
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
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の開発ツール
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側でインライン化したりしました…。地味に面倒。
.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でやれば負荷とかも軽くなったのかもしれない…。