はじめに
過去にRubyを触った経験はありますが、当時は断片的にしか基礎構文・概念も理解できませんでした。そこから一念発起して、学び直しています。
そして、いざ「トランプゲーム」を一から作るとなると右往左往する日々でした。その中で、完成度の高いコードよりもどう設計して問題解決のために思考を積み上げたかその過程にこそ価値があるように感じました。
本記事では、Ruby基礎学習後、トランプゲーム「戦争」を自分なりに実装して学んだこと、過程、思考についてまとめております。
前提
- VScode
- Mac M1
- Ruby基礎、オブジェクト指向
- 実装する際の思考がメイン
- トランプゲーム戦争
完成コード
オブジェクト指向化しRubocopにてコード整形しております。
※Zonesは「場札(引き分け時に一時的に貯めるカード)」を管理するクラス。
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回勝負」という最小単位から始めて、頭の中を整理するためにあえて乱雑にコメントを残しながら進めました。
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)」などといったクラスを作り、責務を分散させる必要があると考えています。
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を補助ツールとして活用することで、自分で実装できる幅を広げることができるように感じます。
おわりに
トランプゲームを自力で実装するのは大変でしたが、自力で試行錯誤して実装した分、力ができたように感じます。
各クラスにおけるメソッドやインスタンス変数、クラス同士を結びつけて処理することなど、以前までブラックボックスに見えていた概念が理解できるようになってきました。
この調子でもっと面白いプログラムやロジックを組めるようになりたいところです。