ADZ 學習筆記

Ruby/Rails, Startup, Life

rails 筆記 - refactor 遊戲商業邏輯心得

| Comments

最近用 rails 寫一個遊戲 api service 在寫的過程除了包 gem 時更了解 rails 外,比較值得拿來分享的大概就 refactor 的心得吧,不過由於 NDA 不能透露太多,所以這裡這篇會用 pseudo 情境來說明。

需求是:

  1. 玩家可以用武器攻擊另外一個玩家
  2. 玩家同時間只能手拿一把武器、可切換不同的武器
  3. 每把武器可能有自己的客制化事件 (ex: 雙重攻擊、無視防禦力、讓對方中毒、擴散攻擊打到附近玩家)
  4. 可隨著遊戲版本而彈性新增武器、防具 .. etc

有以下 models

  1. Player - 遊戲玩家 (has_one :weapon)
  2. Weapon - 武器
  3. Armor - 防具
  4. AttackLog - 攻擊記錄 (belongs_to :attacker (player), :defender (player), :weapon)

分析了需求以後歸納 攻擊 這個動作實際程式流程大概會是:

  1. 先準備好資料,包含攻擊者、被攻擊者的資料、攻擊者的武器、被攻擊者的防具 ... etc
  2. 先 lock db 一些資料避免 race condition
  3. 檢驗一些傳入資料的正確性 (ex: 攻擊者、與被攻擊者的距離超過一定範圍則不能攻擊 ... etc)
  4. 如果武器含有事件是無視防禦力,先把防禦力變數設定成 0
  5. 開始計算攻擊力、防禦力綜合計算後決定被攻擊者要損多少血
  6. 如果武器有雙重攻擊就把剛剛計算的損血量 * 2
  7. 如果武器特性是讓對方中毒,就更新被攻擊者的 status 變成 poisoning
  8. 如果會擴散攻擊,就拿被攻擊者的 location 去搜尋附近 2x2 位置的所有 user
  9. 最後把上述所有計算資料寫入 attack_logs 並扣掉拿以上計算值更新被攻擊者
  10. 送出 websocket events

其實以上不是重點,那只是一開始歸納出來,會有 "好多好多好多" 事情要做,但不論程式多複雜,至少希望在使用上可以簡單,於是就是使用單一入口的 service object,複雜的東西就封裝在 service 裡面,於是我設計了以下的 interface。

# POST /attackes.json

def create
  @defender = Player.find(params[:player_id])
  @attack_response = Player::Attack.call(current_player, @defender)
  # 然後 render create.json.jbuilder 把內容印出來

end

1. 每個部分都需要同樣的 Context

從一開始實作的時候我就打算要把程式拆細,一旦亂到無法整理就完了,於是我寫了很多個 class 把攻擊這個 service 拆成不同的 part,不過過程中綁手綁腳,class 要丟很多變數給另外一個 class,好像拆開反而更複雜,思考的過程有點忘了,不過從 class 拆法換成 module 後歸納出原來問題是在 context 上。

意思是說:每一個 module 都會需要 attacker, defender, attacker.weapon, defender.armor 這些資料,而除了這些資料外,也要能夠隨時 override 已經算好的資料 (被攻擊的人開損多少血 ... etc)。

於是第一次 refactor 把檔案結構改成以下,並把相關的 methods 做個歸類,並由 attack.rb 來組織所有流程:

  1. attack.rb
  2. concerns/prepare.rb # 準備資料的 methods
  3. concerns/validation.rb # 使用 activerecord::validation 寫的 custom validation method
  4. concerns/attack_calculator.rb # 計算攻擊、防禦的傷害值
  5. concerns/weapon_events.rb # 武器事件的 methods
  6. concerns/attack_logs_handler.rb # 最後儲存資料 (attack_logs, 扣血量 ...) 的 method
  7. concerns/websockets.rb # 最後處理 realtime 的 methods

於是我的 attack.rb 變成了類似:

class Player::Attack
  [Prepare, Validation, AttackCalculator ....].each { |m| include m }
  
  ValidFaildError = Class.new(StandError)
  
  def call!
    ActiveRecord::Base.transaction do
      prepare_player_data! # in prepare.rb

      lock_data! # in prepare.rb

      
      raise ValidFaildError if valid? # in validation.rb

      
      @defense = defender.defense # 防禦力

      @attack = attacker.attack # 防禦力

      @extend_status = []
      
      @defense = 0 if weapon_ignore_armor?
      
      @damage_hp = calculating_damage
      @damage_hp *= 2 if weapon_with_double_attack?
      @extend_status.push(:poisoning) if weapon_with_poisoning?
      
      damage_defender(@damage_hp, @extend_status)
      save_all! # 儲存 attack logs, attacker, defender

    end
    Websocket::API.broadcast!('some channels', @attack_log)
    true
  end
end

2. 使用 ActiveSupport::Callback

以上程式雖然把 methods 都丟到 module 內,但還是有一個問題,如果我的需求不斷增加,那我的 call! method 不是寫到爆炸了嗎? 觀察了這些流程,發現武器這塊很棘手,他圍繞在 calculating_damage 前後 間接直接 影響著失血計算,如果不考量武器,其他的部分算是攻擊的 基本流程,如果能把讓主要程式只負責基本的攻擊功能,像是 capistrano 提供基本的 tasks 由 hooks 去組織出個人化的流程,就可以在未來新增各種不同 module 的時候完全不動到主結構。

所以我用 activesupport 進行了第二次大 refactor 提供了像是 activerecord callback 的功能:

  1. before_validation # 檢驗前
  2. after_validation # 檢驗後
  3. before_prepare_calculating_data # 初始化計算基礎資料 (攻擊力、防禦力、距離 .. 其他因素 )
  4. after_prepare_calculating_data # 初始化完成後 (設定完成後)
  5. before_calculating_damage # 以初始化資料進行傷害計算前
  6. after_calculating_damage # 以初始化資料進行傷害計算後
  7. before_commit # 儲存資料前
  8. after_commit # 最後統一儲存 attack_logs 和攻擊者、被攻擊者後

由以上 callback 我能夠把除了主攻擊邏輯之外的邏輯放在 module 內,類似這樣的 code

module Player::Attack::WeaponEvents
  extend ActiveSupport::Concern
  included do
    after_calculating_damage {
      @extend_status.push(:poisoning) if weapon_with_poisoning?
    }
  end
end

有了以上架構,我甚至還能擴充更多不同的武器事件,例如:

  1. 有一定機率觸發 2 被攻擊
  2. 當被攻擊者寫少於 100 時,觸發 xxxx yyyy 事件
  3. 吸血功能武器

3. 把武器事件拆出

雖然 callbacks 的設計方式讓組織流程更順利,module 間各自獨立並提升了可讀性,不過另一個問題是:難道我有 100 個武器事件都要寫在 WeaponEvents 嗎? 而且當武器事件增加,就顯得 WeaponEvents 有過多無謂的運算。

於是也許我們可以為每個武器各寫一個 class,由 weapon::base 裡面提供定義遊戲事件的功能。

class Weapon::Base
  class << self
    def before_calculating_damage(&block)
      # .. 略

    end
    
    def run_before_calculating_damage(context)
      # .. 略

    end
    
    def after_calculating_damage(&block)
      # .. 略

    end
   
    def run_aftr_calculating_damage(context)
      # .. 略

    end
  end
end


class Weapon::Gun < Weapon::Base
  before_calculating_damage do |context|
    # 70% 機率對手防禦力 - 10

    context.defense -= 10 if rand(100) >= 30
  end
  
  after_calculating_damage do |context|
    # 40% 機率傷害增加 3 倍

    context.damage_hp *= 3 if rand(100) >= 60 
  end
end

再由 weapon 記錄該武器是哪個 class 的。

class Weapon < ActiveRecord::Base
  def weapon_model
    # weapon_model ex: `Weapon::Knife`, `Weapon::Gun` .. etc

    @weapon_model ||= weapon_model_name.constantize
  end
end

class Player < ActiveRecord::Base
  has_one :weapon
  
  delegate :weapon_model, to: :weapon
end

這樣一來我就可以避免 WeaponEvents 裡面有太多 implementation,取而代之只需要提供 helper 就好

module Player::Attack::WeaponEvents
  extend ActiveSupport::Concern
  included do
    before_calculating_damage { @attacker.weapon_model.run_before_calculating_damage(self) }
    after_calculating_damage { @attacker.weapon_model.run_after_calculating_damage(self) }
  end
  
  # helper methods .. (略)

end

5. 成果

Refactor 到這個地步的成果是:每增加一款武器加上特殊事件,只需要 20-30 分鐘就能完成包括補上 spec。

6. 雞蛋裏挑骨頭

不過還是希望在使用上有 rails 社群專有優雅感,於是我放棄了 service object 的想法而把 Player::Attack 改成了 module mix 到武器的 class 上,再搭配 ActiveRecord delegate,最後使用的方式變成:

@player = Player.find(2)
@player2 = Player.find(3)
@player.attack!(@player2)

修改則是把 Player::Atack 改名成為 Weapon::Attack 所有武器的 class 改成:

class Weapon::Gun < Weapon::Base
  include Attack
  # ... (略)

end

這樣改的好處是:之前的做法太囉唆了,還要讓 WeaponEvents 把 context 傳到 Weapon classes,但 武器事件 跟整個 攻擊流程 有同樣的 context 本來就很合理,但壞處是武器 class 如果很多,同樣的 module 一直 mixin 到不同的 classes 不知道記憶體會不會爆炸 (如果有人知道這種情況請告訴我,感謝)。

7. 最後

從一開始接觸到這需求很頭痛,到 refactor 到目前的版本,最大的體會是:想一次到位是很不切實際的想法,很多 idea 是借由整理程式碼再加上時間才能萃取出來的。

其他感想就是:寫文章跟 refactor 很像,應該切成多個 part 分批進行。

Comments

comments powered by Disqus