TIL

Golangでブラックジャックを実装してみた

新しい言語を覚えるにはブラックジャックを実装すると良い。と聞いたのでGolangでブラックジャックを書いてみた。

Golangを勉強し始めてまだ日が浅いので、スタンダードでない書き方をしている可能性がある。もし検索からこの記事に辿り着いたとしても、参考程度に留めて頂けると幸いである。

仕様

ベットまで実装するとかなりハードそうだったので、初心者向けによく言われる仕様にとどめた。

  • カードは52枚でシャッフルされている
  • 一度引いたカードは重複しない
  • プレイヤーとディーラーの1対1
  • ディーラーの初期ハンドは1枚目のみが可視
  • プレイヤーはBUSTするまでカードを引くかの選択ができる
  • ディーラーは17以上になるまで引き続ける
  • 初期手札の2枚で21ができたらブラックジャック
  • ブラックジャックと通常の21ではブラックジャックが勝つ
  • AはBUSTしないなら11、BUSTする場合は1として数える

設計と実装

Golangなのでクラスではなく構造体を作る。

プレイヤーの構造体から考えようかと思ったが、プレイヤーはメンバに手札を持つはずなので、トランプのカードから順に考えていくことにした。

Card

まずは最小単位のカードで型を作る。

構造体にしようかと思ったが、intで十分そうだったのでintのユーザ定義型にした。

変数としては、4種類のマークと13種類の英数字、各カードのブラックジャック上での点数をそれぞれ配列で保持した。Card型で保持している整数が添字になる。

もし継承が実装された言語だったら、マークと英数字は基底クラスに持たせて、ブラックジャック上の点数をブラックジャック用のサブCardクラスに持たせたりするかもしれない。

Card型はメソッドを4つ持ち、表示用の文字列を返すString()とブラックジャック上の点数を返すScore()は公開にした。

card.go
package card

// カードを表す型
// 0〜51で1周する
type Card int

var suitMark = [4]string{"♠", "♥", "◆", "♣"}
var displayNumber = [13]string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K"}
var score = [13]int{11, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10}

// 表示用の文字列を返す
func (c Card) String() string {
	return suitMark[c.suitNumber()] + displayNumber[c.number()]
}

// 0〜12でカードの数を表す
func (c Card) number() int {
	return int(c) % 13
}

// 0〜3でスートを表す
func (c Card) suitNumber() int {
	return int(c) / 13
}

// ブラックジャックとして使われる数
func (c Card) Score() int {
	return score[c.number()]
}

Deck

カードの次はカードの集合である山札を型として作った。

今度は構造体にしてみた。

要素数52のCardスライスを初期化で作らせたかったので、擬似的なコンストラクタ関数を作った。初期化でついでにシャッフルもしてしまおうか迷ったが、新品のトランプはシャッフルされていないものなのでやめた。

スライスのシャッフルと先頭要素の取り出しを実装したものの、どちらもあまり見た目が良くない。要素削除のコードは性能的に問題がありそうなので、削除ではなく番兵に置き換える方がよいかもしれない(そうすると先頭要素取り出しの計算量が増えてしまうが)。

このあたりで少しRubyが恋しくなった。

deck.go
package card

import (
	"math/rand"
	"time"
)

// カードデッキ
// InitDeck()で初期化
type Deck struct {
	cards []Card
}

// デッキの初期化用関数
func InitDeck() *Deck {
	deck := Deck{
		cards: make([]Card, 52),
	}

	for i := 0; i < 52; i++ {
		deck.cards[i] = Card(i)
	}
	return &deck
}

// デッキをシャッフルする
func (d *Deck) Suffule() {
	if d.HowMany() < 2 {
		return
	}
	rand.Seed(time.Now().UnixNano())
	rand.Shuffle(len(d.cards), func(i, j int) { d.cards[i], d.cards[j] = d.cards[j], d.cards[i] })
}

// デッキの一番上のカードを取り出す
func (d *Deck) Pop() Card {
	// デッキが枯れたケースは考慮しない
	c := d.cards[0]
	// 先頭の要素を削除。unshiftが欲しい
	d.cards = append(d.cards[:0], d.cards[1:]...)
	return c
}

// デッキの残り枚数を返す
func (d Deck) HowMany() int {
	return len(d.cards)
}

Player

大体の言語ではプレイヤーとディーラーは継承を使って別クラスに分けそう(多分自分もそうする)だが、Golangには継承がないのでどうするか頭を捻った。

二重に実装する案も考えたが、カードに対するプレイヤーとディーラーの差分はアップカードくらいだったので1つの型に押し込んでファクトリ関数で分岐させることにした。(2枚目の手札を隠すのは、後述する上位のGame型にやらせた)

プレイヤーはブラックジャックのルールを知っているものとして、手札の範囲内の情報を返すメソッドを実装した。

手札のスコアを返すHandsNumberメソッドにA(エース)の特殊ルールが入り込んでいるが、Aを1とするか11とするかはプレイヤーが自分の手札に応じて決めることなので、収まる場所としては適切なのではと考えた。

player.go
package player

import (
	"blackjack/internal/card"
	"strings"
)

// プレイヤー or ディーラー
type Player struct {
	hands    []card.Card
	name     string
	isDealer bool // 結局使わなかった
}

// プレイヤーを生成するファクトリ関数
func InitPlayer() *Player {
	player := Player{
		name:     "あなた",
		isDealer: false,
	}
	return &player
}

// ディーラーを生成するファクトリ関数
func InitDealer() *Player {
	dealer := Player{
		name:     "ディーラー",
		isDealer: true,
	}
	return &dealer
}

// デッキからカードを1枚引く
func (p *Player) Hit(deck *card.Deck) card.Card {
	card := deck.Pop()
	p.hands = append(p.hands, card)
	return card
}

// ハンドの合計値を返す
func (p Player) HandsNumber() int {
	sum := 0
	ace := 0
	for _, v := range p.hands {
		sum += v.Score()
		// Aの場合は合計に対して例外処理を行う
		if v.Score() == 11 {
			ace++
		}
	}

	// ハンドが21を超える場合はAを1と数える
	// Aの枚数分行う
	for i := ace; 0 < i && 21 < sum; i-- {
		sum -= 10
	}
	return sum
}

// ハンドの文字列表現を返す
func (p Player) HandsString() string {
	cards := make([]string, len(p.hands))
	for i, v := range p.hands {
		cards[i] = v.String()
	}
	return strings.Join(cards, " ")
}

// ハンドがブラック・ジャックかを返す
func (p Player) HasBJ() bool {
	// ハンドが2枚で21の場合はブラックジャック
	return len(p.hands) == 2 && p.HandsNumber() == 21
}

// ハンドがBUSTかを返す
func (p Player) IsBust() bool {
	return p.HandsNumber() > 21
}

// 名前を返すGetter
func (p Player) Name() string {
	return p.name
}

Game, Result

mainから呼ばれる最上位をGame型とした。

標準出力やユーザからの入力受付など、面倒なことを一身に背負った型なのでコードの見た目が大分悪い。普通にprintlnすると恐ろしく速いブラックジャックになって見逃されてしまうので、Sleepを挟む関数を作り始めて余計に見た目が悪くなった。

あとは勝敗を定数で持つResult型。作らなくても良かったが、enumを実装したい時はどうするのか気になったので作ってみた。iotaの活躍の場がわかった気がした。

ここのコードは長いので折りたたんである。

Game型, Result型
game.go
package game

import (
	"blackjack/internal/card"
	"blackjack/internal/player"
	"bufio"
	"fmt"
	"os"
	"strings"
	"time"
)

type Game struct {
	deck   card.Deck
	player player.Player
	dealer player.Player
}

func InitGame() *Game {
	game := Game{}
	game.deck = *card.InitDeck()
	game.player = *player.InitPlayer()
	game.dealer = *player.InitDealer()
	return &game
}

func (g *Game) Play() {
	// デッキをシャッフルする
	g.deck.Suffule()

	// 2枚ずつ配る
	g.dealer.Hit(&g.deck)
	g.dealer.Hit(&g.deck)
	g.player.Hit(&g.deck)
	g.player.Hit(&g.deck)

	// プレイヤーのターン
	// BUSTするまでプレイヤーはカードを引く選択をする
	g.playerTurn()

	// ディーラーのターン
	// プレイヤーがBUSTした場合は勝敗判定へ移る
	if !g.player.IsBust() {
		g.dealerTurn()
	}

	// 勝敗判定
	result, msg := g.judge()
	g.printResult(result, msg)
}

// YesかNoをユーザに問う
func askYorN() string {
	print("(Y/n) >")
	scanner := bufio.NewScanner(os.Stdin)
	for {
		scanner.Scan()
		in := scanner.Text()

		switch in {
		case "y", "Y":
			return "Y"
		case "n", "N":
			return "n"
		default:
			println("Yかnを入力してください")
		}
	}
}

// ゲームの結果を返す
func (g Game) judge() (Result, string) {
	switch {
	case g.player.IsBust():
		return Lose, g.player.Name() + "のBUSTです"
	case g.dealer.IsBust():
		return Win, g.dealer.Name() + "のBUSTです"
	case g.player.HasBJ() && !g.dealer.HasBJ():
		return Win, g.player.Name() + "のBlackJackです"
	case !g.player.HasBJ() && g.dealer.HasBJ():
		return Lose, g.dealer.Name() + "のBlackJackです"
	case g.player.HasBJ() && g.dealer.HasBJ():
		return Push, "BlackJack同士は引き分けです"
	case g.dealer.HandsNumber() > g.player.HandsNumber():
		return Lose, g.dealer.Name() + "のハンドが上です"
	case g.player.HandsNumber() > g.dealer.HandsNumber():
		return Win, g.player.Name() + "のハンドが上です"
	default:
		return Push, "同点です"
	}
}

// プレイヤーがカードを引く
func (g *Game) playerTurn() {
	delayPrintln(fmt.Sprintf("<<<<< %sのターン >>>>>", g.player.Name()))
	printUpCard(g.dealer)
	// BUSTするまでカードを引く選択を繰り返す
	for !g.player.IsBust() {
		printHands(g.player)
		println()
		print("1枚引きますか?")

		if askYorN() == "Y" {
			card := g.player.Hit(&g.deck)
			println("引いたカード: " + card.String())
		} else {
			break
		}
	}
}

// ディーラーがカードを引く
func (g *Game) dealerTurn() {
	delayPrintln(fmt.Sprintf("<<<<< %sのターン >>>>>", g.dealer.Name()))
	printHands(g.dealer)
	// 17以上になるまで引き続ける
	for g.dealer.HandsNumber() <= 16 {
		card := g.dealer.Hit(&g.deck)
		delayPrintln("引いたカード: " + card.String())
	}
}

// プレイヤーのハンドと数値を標準出力する
func printHands(p player.Player) {
	delayPrintln(fmt.Sprintf("%s >> %s (%d)", p.Name(), p.HandsString(), p.HandsNumber()))
}

// ハンドの1枚目だけを標準出力する
// ディーラー用
func printUpCard(p player.Player) {
	maskHand := strings.Split(p.HandsString(), " ")[0] + " ??"
	println(fmt.Sprintf("%s >> %s", p.Name(), maskHand))
}

// 勝負の結果を標準出力する
func (g Game) printResult(result Result, message string) {
	delayPrintln("==========================")
	printHands(g.dealer)
	printHands(g.player)
	delayPrintln("結果: " + result.String())
	delayPrintln(message)
}

// 少し待ってから表示する
func delayPrintln(str string) {
	time.Sleep(time.Millisecond * 500)
	println(str)
}
result.go
package game

// ゲームの勝敗を表す疑似enum型
type Result int

const (
	Win Result = iota
	Lose
	Push
)

func (r Result) String() string {
	switch r {
	case Win:
		return "勝ち"
	case Lose:
		return "負け"
	case Push:
		return "引き分け"
	default:
		return ""
	}
}

所感

Golangの勉強のつもりで始めたものの、言語関係なくブラックジャックの実装が面白かった。ループ、継承、配列操作、ビジネスロジック、標準入出力など様々な要素が含まれていて、学習用途で選ばれる理由がよくわかった。

Golangに関しては、フォーマッタのルールが強制適用される点や静的型付け言語の正確なIDE補完によって快適にプログラミングできたのが良かった。メソッドを構造体の外側に書くのに馴染めるか不安だったが、書いていると全然気にならなかったし、ネストが浅くなるメリットも感じられた。

一方でスライス操作の表現力にはだいぶ課題がありそう。データ構造は必要に応じてサードパーティのライブラリで補うことになるかもしれない。

あと地味にパッケージ構成とinternalの置き場で迷った。

参考文献

https://qiita.com/hirossyi73/items/cf8648c31898216312e5


まだGolangに触れて間もないが、今の所は仲良くなれそうな気がしている。早く現場のプロダクトコードをGolangで書いてみたい。今日は眠いのでもう寝る。


<< 記事一覧へ