2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Ruby】トランプゲーム「戦争」実装で学んだ「小さく作る」と責務分離

2
Last updated at Posted at 2026-01-25

はじめに

過去にRubyを触った経験はありますが、当時は断片的にしか基礎構文・概念も理解できませんでした。そこから一念発起して、学び直しています。

そして、いざ「トランプゲーム」を一から作るとなると右往左往する日々でした。その中で、完成度の高いコードよりもどう設計して問題解決のために思考を積み上げたかその過程にこそ価値があるように感じました。

本記事では、Ruby基礎学習後、トランプゲーム「戦争」を自分なりに実装して学んだこと、過程、思考についてまとめております。

前提

完成コード

オブジェクト指向化しRubocopにてコード整形しております。

※Zonesは「場札(引き分け時に一時的に貯めるカード)」を管理するクラス。

Ruby
class Player
  attr_reader :name
  attr_accessor :cards, :hands

  def initialize(name)
    @name = name
    @cards = []
    @hands = nil
  end

  def print_players_card
    cards.each(&:print_card)
  end

  def play_card
    @hands = @cards.shift
  end

  def show_hand
    puts "#{@name}のカードは#{@hands.print_card}です。"
  end
end

class Card
  attr_reader :suit, :value, :int_value

  def initialize(suit, value, int_value)
    @suit = suit
    @value = value
    @int_value = int_value
  end

  def print_card
    "#{@suit}#{@value}(#{@int_value})"
  end
end

class Zones
  attr_reader :draw

  def initialize
    @draw = []
  end

  def clear
    @draw.clear
  end

  def size
    @draw.size
  end

  def collect(cards)
    @draw.concat(cards)
  end
end

class Deck
  attr_reader :deck

  def initialize(player_count)
    @deck = generate_deck(player_count)
  end

  def generate_deck(player_count)
    suits =
      case player_count
      when 2 then ['♠︎']
      when 3 then ['♠︎', '♣︎']
      when 4 then ['♠︎', '♣︎', '❤︎']
      when 5 then ['♠︎', '♣︎', '❤︎', '♦︎']
      else        ['♠︎']
      end
    values = %w[2 3 4 5 6 7 8 9 10 J Q K A]

    new_deck = []
    suits.each do |s|
      values.each_with_index do |v, i|
        new_deck.push(Card.new(s, v, i + 2))
      end
    end
    new_deck
  end

  def printDeck
    @deck.each(&:print_card)
  end

  def shuffle
    @deck.shuffle!
  end

  def size
    @deck.size
  end

  def distribution(players)
    @deck.length.times do |i|
      players[i % players.length].cards << @deck[i]
    end
  end
end

class War
  attr_accessor :players

  def initialize
    @start = false
    @players = []
    @parent = nil
    @show_cards = {}
    @ranking = []
  end

  def setting
    puts '戦争を開始します。'

    count = nil

    loop do
      print 'プレイヤーの人数を入力してください(2〜5): '
      count = gets.to_i

      if (2..5).include?(count)
        @start = true
        break
      else
        puts '入力を間違えているのでやり直してください。'
      end
    end
    count.times do |i|
      print "プレイヤー#{i + 1}の名前を入力してください: "
      name = gets.chomp
      @players << Player.new(name)
    end
    @parent = @players.sample
  end

  def check_round(round)
    if round > 50
      puts '', "ラウンドが50を超えたので終了します。\n持ってるカードの枚数、脱落順の順位となります。", ''

      sorted_players = @players.sort_by { |player| player.cards.length }
      all_players = sorted_players.reverse + @ranking.reverse

      rank = 1
      all_players.each do |player|
        puts "#{rank}位:#{player.name}はカードを#{player.cards.length}枚持っています。"
        rank += 1
      end

      exit
    end
  end

  def check_loser
    losers = @players.select { |player| player.cards.empty? }
    losers.each do |loser|
      puts "#{loser.name}はカードがなくなり脱退しました。"
      @ranking << loser
      @players.delete(loser)
    end
  end

  def start
    setting
    deck = Deck.new(@players.length)
    deck.shuffle
    deck.distribution(@players)

    puts '', "カードは合計で#{deck.size}枚です。"
    @players.each do |player|
      puts "#{player.name}のカードは#{player.cards.length}枚です。"
    end

    draw = Zones.new

    round = 0
    loop do
      round += 1

      check_round(round)

      if @players.length == 1
        @ranking << @players[0]
        puts '', '戦争を終了します。順位は以下の通りです。', ''
        @ranking.reverse.each_with_index do |player, i|
          puts "#{i + 1}位:#{player.name}\n手札の枚数は#{player.cards.length}枚です。", ''
        end
        exit
      end

      @players.each(&:play_card)

      puts '', '戦争!'

      @show_cards.clear

      @players.each do |player|
        player.show_hand
        @show_cards[player] = player.hands.int_value
      end

      max_value = @show_cards.values.max
      max_count = @show_cards.values.count(max_value)
      table_cards = @players.map(&:hands)

      if max_count == 1
        winner = @show_cards.key(max_value)
        puts "#{winner.name}が勝ちました。#{winner.name}はカードを#{@players.length + draw.size}枚もらいました。"
        winner.cards.concat(table_cards)
        winner.cards.concat(draw.draw)
        draw.clear
      else
        draw.collect(table_cards)
        puts '引き分けです。'
      end
      check_loser
    end
  end
end

war = War.new
war.start

どうやって実装したか

いきなり問題を見て実装することは不可能なので、問題を小さく分解、ロジック実装、オブジェクト指向化しクラス設計、機能追加のように進めていきました。

小さく実装

いきなり全て実装しようとすると全身がフリーズするため、全ての粒度を小さくして進めていきました。

具体的に

  • 処理はメソッド1つから
  • タスク・実装内容を小さく分解する
  • ロジック・処理はコメントで書いて実装内容を明確にしてからコードを書く

です。

最初にコードを書くときは、メインロジックをwarメソッドとして構築し、カードの強さ判定をcardStrongsメソッド(※後にオブジェクト指向化で整理)として切り出しました。

まずは「2人プレイ・1回勝負」という最小単位から始めて、頭の中を整理するためにあえて乱雑にコメントを残しながら進めました。

Ruby
def war
  player1 = []
  player2 = []
  players = [player1, player2]

  # 親を決める
  parent = "player" + (rand(players.length) + 1).to_s

  # カードを52枚用意
  card_spade = ["スペードの2","スペードの3","スペードの4","スペードの5","スペードの6","スペードの7","スペードの8","スペードの9","スペードの10","スペードのJ","スペードのQ","スペードのK","スペードのA"]
  card_club = ["クラブの2","クラブの3","クラブの4","クラブの5","クラブの6","クラブの7","クラブの8","クラブの9","クラブの10","クラブのJ","クラブのQ","クラブのK","クラブのA"]
  card_heart = ["ハートの2","ハートの3","ハートの4","ハートの5","ハートの6","ハートの7","ハートの8","ハートの9","ハートの10","ハートのJ","ハートのQ","ハートのK","ハートのA"]
  card_dia = ["ダイヤの2","ダイヤの3","ダイヤの4","ダイヤの5","ダイヤの6","ダイヤの7","ダイヤの8","ダイヤの9","ダイヤの10","ダイヤのJ","ダイヤのQ","ダイヤのK","ダイヤのA"]

  # カードの全てを格納してシャッフルする
  deck = (card_spade + card_club + card_heart + card_dia).shuffle

  puts '戦争を開始します。'
  # 52個あるカードでループし、均等にプレーヤーに配る
  (deck.length).times do |i|
    players[i % players.length] << deck[i]
  end
  puts 'カードが配られました。'

  # 引き分け時にカードを保持する
  draw = []

  while true do
    # 終了判定!
    if player1.empty? || player2.empty?
      p1_total = player1.length
      p2_total = player2.length

      winner = ''
      if player1.empty?
        puts 'プレイヤー1の手札がなくなりました。'
        p2_total += draw.length
        winner = 'プレイヤー2が1位、プレイヤー1が2位です。'
      elsif player2.empty?
        puts 'プレイヤー2の手札がなくなりました。'
        p1_total += draw.length
        winner = 'プレイヤー1が1位、プレイヤー2が2位です。'
      end
      puts "プレイヤー1の手札の枚数は#{p1_total}枚です。プレイヤー2の手札の枚数は#{p2_total}枚です。"
      puts winner

      puts '戦争を終了します。'
      exit
    end

    # 先頭の手札を取り出す
    p1 = player1.shift
    p2 = player2.shift

    puts '戦争!'

    puts "プレイヤー1のカードは#{p1}です。"
    puts "プレイヤー2のカードは#{p2}です。"

    # player1,player2で配列の0番目にあるカードを展開して強さを比べる
    if cardStrongs(p1) > cardStrongs(p2)
      puts "プレイヤー1が勝ちました。プレイヤー1はカードを#{players.length + draw.length}枚もらいました。"
      player1 << p1 << p2
      player1.concat(draw)
      draw.clear
    elsif cardStrongs(p1) < cardStrongs(p2)
      puts "プレイヤー2が勝ちました。プレイヤー2はカードを#{players.length + draw.length}枚もらいました。"
      player2 << p1 << p2
      player2.concat(draw)
      draw.clear
    else
      draw << p1 << p2
      puts '引き分けです。'
    end
  end
end

# カードの得点取得
def cardStrongs(card)
    strongs = {
        "2" => 2,
        "3" => 3,
        "4" => 4,
        "5" => 5,
        "6" => 6,
        "7" => 7,
        "8" => 8,
        "9" => 9,
        "10" => 10,
        "J" => 11,
        "Q" => 12,
        "K" => 13,
        "A" => 14
    }

    # カードから数字とアルファベットを取得
    matches = card.scan(/[A-Z0-9]/)
    # ハッシュから得点を取得
    score = strongs[matches.join]
    score
end

war

オブジェクト指向化

単一責任の法則」を意識してコードをオブジェクト指向化してクラスに処理をまとめました。

単一責任の法則
クラスにあれもこれもと役割を詰め込まないで一つの役割を持たせること。
例えるなら、企業における経理、総務、情報戦略、現場のように職務を分離してそれぞれの職務にだけ責任を持たせること。

いきなりオブジェクト指向化するのは難しいので、必要なクラスの選定、クラスを書いて持つべき情報、既存メソッドを分解してクラスに与えていきました。

クラスを分ける際、「この処理は君がやるべきだ」「この処理は君の職務だね?」と、擬人化して問いかけるように設計しました。

私の場合、簡潔に下記のような役割をイメージして作りました。

  • Playerクラス:名前を持ち、手札(カード)を管理する
  • Cardクラス:自分の数字やスート、得点を知っている
  • Zonesクラス:手札に関する処理とし、引き分けカードの保持と処理
  • Deckクラス:カード情報を組み合わせてデッキを作るようにして、プレイヤーに分配する
  • Warクラス:ゲーム進行してその他処理を請け負い全てのクラスと連携する神

課題と感じること

ここまで結構書いてきましたが、課題もあります。

神クラス(God Object)からの脱却

現状、Warクラスがゲームの進行からルール判定までを担う「神クラス」になっています。

これは単一責任の法則から外れるため、今後「審判クラス(Judge)」や「進行管理(GameMaster)」などといったクラスを作り、責務を分散させる必要があると考えています。

Ruby
class War
  attr_accessor :players

  def initialize
    @start = false
    @players = []
    @parent = nil
    @show_cards = {}
    @ranking = []
  end

  def setting
    puts '戦争を開始します。'

    count = nil

    loop do
      print 'プレイヤーの人数を入力してください(2〜5): '
      count = gets.to_i

      if (2..5).include?(count)
        @start = true
        break
      else
        puts '入力を間違えているのでやり直してください。'
      end
    end
    count.times do |i|
      print "プレイヤー#{i + 1}の名前を入力してください: "
      name = gets.chomp
      @players << Player.new(name)
    end
    @parent = @players.sample
  end

  def check_round(round)
    if round > 50
      puts '', "ラウンドが50を超えたので終了します。\n持ってるカードの枚数、脱落順の順位となります。", ''

      sorted_players = @players.sort_by { |player| player.cards.length }
      all_players = sorted_players.reverse + @ranking.reverse

      rank = 1
      all_players.each do |player|
        puts "#{rank}位:#{player.name}はカードを#{player.cards.length}枚持っています。"
        rank += 1
      end

      exit
    end
  end

  def check_loser
    losers = @players.select { |player| player.cards.empty? }
    losers.each do |loser|
      puts "#{loser.name}はカードがなくなり脱退しました。"
      @ranking << loser
      @players.delete(loser)
    end
  end

  def start
    setting
    deck = Deck.new(@players.length)
    deck.shuffle
    deck.distribution(@players)

    puts '', "カードは合計で#{deck.size}枚です。"
    @players.each do |player|
      puts "#{player.name}のカードは#{player.cards.length}枚です。"
    end

    draw = Zones.new

    round = 0
    loop do
      round += 1

      check_round(round)

      if @players.length == 1
        @ranking << @players[0]
        puts '', '戦争を終了します。順位は以下の通りです。', ''
        @ranking.reverse.each_with_index do |player, i|
          puts "#{i + 1}位:#{player.name}\n手札の枚数は#{player.cards.length}枚です。", ''
        end
        exit
      end

      @players.each(&:play_card)

      puts '', '戦争!'

      @show_cards.clear

      @players.each do |player|
        player.show_hand
        @show_cards[player] = player.hands.int_value
      end

      max_value = @show_cards.values.max
      max_count = @show_cards.values.count(max_value)
      table_cards = @players.map(&:hands)

      if max_count == 1
        winner = @show_cards.key(max_value)
        puts "#{winner.name}が勝ちました。#{winner.name}はカードを#{@players.length + draw.size}枚もらいました。"
        winner.cards.concat(table_cards)
        winner.cards.concat(draw.draw)
        draw.clear
      else
        draw.collect(table_cards)
        puts '引き分けです。'
      end
      check_loser
    end
  end
end

実はルール通りではない

  • 最強のカード(ジョーカー)がなし
  • 無条件で場札をとることができる「スペードA」実装していない
  • 獲得札は手札が0になってから補充する必要があるが、勝負が決まってから補充している
  • シャッフルしてゲームを回すと無限ループになるためラウンド50までとしている

ルール通りに実装しようとしたところ、どうやって実装すべきかロジックが思いつかなかったり、カードの枚数への不整合、無限ループが生じたりして実装に行き詰まるところが多々ありました。

自分にとってどこまで理解できたから実装できて、どこから何がわからなくて実装できないのか言語化して根気強く調査していくことが大事だと思いました。

コード整理

  • リファクタリング
  • クラスをフォルダで管理
  • 命名
  • コメント

コードを見るとまだまだ改善ポイントがあります。他の書き方を調べたり、AIを補助ツールとして活用することで、自分で実装できる幅を広げることができるように感じます。

おわりに

トランプゲームを自力で実装するのは大変でしたが、自力で試行錯誤して実装した分、力ができたように感じます。

各クラスにおけるメソッドやインスタンス変数、クラス同士を結びつけて処理することなど、以前までブラックボックスに見えていた概念が理解できるようになってきました。

この調子でもっと面白いプログラムやロジックを組めるようになりたいところです。

2
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?