Goでブラックジャックを作ってみた

2022-12-19

はじめに

初めまして。Belong inc でバックエンドエンジニアを担当している mohiro@です。
私は未経験採用枠として入社し、今月で 5 ヶ月が経過しました。 日々の業務ではわからないことも多い中、チームメンバーの先輩方からの手厚いサポートを頂き、毎日成長を実感しながら過ごしています。 今回は私が Go の学習目的で個人で作った制作物と学びをまとめました。
golang で何か作りたいけど作りたいものが無いという方の参考になれば嬉しく思います。

制作物

トランプのブラックジャック
起動するとコンソール上で遊ぶことができます。 https://github.com/moss1232/go_blackjack

demo

実装内容

ブラックジャックは細かいルールまで実装するとかなりロジックが複雑になるのでこちらの記事を参考に以下のような簡単なルールで実装しました。

  • 初期カードは 52 枚。
  • 実行開始時、プレイヤーとディーラーは一枚ずつカードを引く
  • プレイヤーが先にカードを引く。 プレイヤーが 21 を超えていたらバーストしてプレイヤーの負け、その時点でゲーム終了
  • プレイヤーは、カードを引くたびに、次のカードを引くか選択できる
  • プレイヤーが引き終えたら、その後ディーラーは、自分の手札が 17 以上になるまで引き続ける
  • プレイヤーとディーラーが引き終えたら勝負。より 21 に近い方の勝ち
  • A,J,Q,K はそれぞれ 1,11,12,13 としてのみ扱う
  • ダブルダウン、スプリット、サレンダーなどの特殊ルールはなし。プレイヤーの行動はヒットとコールのみ

ディレクトリ構成

.
├── Makefile
├── deck.go
├── go.mod
├── main.go
├── phase.go
└── player.go

main.go

package main

func main() {
	dc := newDeck()
	p := newPlayer("player")
	dl := newPlayer("dealer")
	setup(p, dl, dc)
	hitOrCall(p, dl, dc, askDraw())
	result(p, dl, dc)
}

setupでゲーム開始の準備、hitOrCallでゲームを実施、resultでゲームの結果を表示します。

player.go

package main

import "fmt"

const (
	SPADE = iota + 1
	CLUB
	HEART
	DIA
)

type player struct {
	role string
	hand []card
}

func newPlayer(role string) *player {
	return &player{
		role: role,
		hand: []card{},
	}
}

func (p *player) renderHand() {
	fmt.Printf("%v ", p.role)
	fmt.Print("Card:")
	for _, h := range p.hand {
		switch h.suit {
		case SPADE:
			fmt.Print("♠")
		case CLUB:
			fmt.Print("♣")
		case HEART:
			fmt.Print("♡")
		case DIA:
			fmt.Print("♢")
		}
		fmt.Printf("%d", h.number)
	}
	fmt.Printf(" Score:%v\n", p.score())
	fmt.Println("----------------------------")
	return
}

func (p *player) hit(d deck) {
	p.hand = append(p.hand, d[0])
	d = append(d[:0], d[1:]...)
}

func (p *player) score() (sum int) {
	for _, v := range p.hand {
		sum += v.number
	}
	return
}

deck.go

package main

import (
	"math/rand"
	"time"
)

type deck []card

type card struct {
	number int
	suit   int
}

//var suit = [4]string{"Spade", "Clover", "Heart", "Diamond"}
//var number = [13]string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}

func newDeck() deck {
	d := make(deck, 0, 52)
	for s := 1; s <= 4; s++ {
		for n := 1; n <= 13; n++ {
			d = append(d, card{suit: s, number: n})
		}
	}
	d.shuffle()
	return d
}

func (d deck) shuffle() {
	rand.Seed(time.Now().UnixNano())
	rand.Shuffle(len(d), func(i, j int) {
		d[i], d[j] = d[j], d[i]
	})
}

Go では map をイテレーションすると取り出す順番が実行ごとに変わってしまいます。
これによる予期せぬエラーを防ぐためにsuitはマークを数字に対応させ、map の代わりに slice を使っています。

phase.go

package main

import (
	"bufio"
	"fmt"
	"os"
	"strconv"
)

func askHit() bool {
	fmt.Println("hit? y/n")
	input := bufio.NewScanner(os.Stdin)
	input.Scan()
	fmt.Println("----------------------------")
	ans := input.Text()
	if ans == "y" {
		return true
	}
	fmt.Println("call")
	return false
}

func setup(p, dl *player, dc deck) {
	dl.hit(dc)
	p.hit(dc)
	p.renderHand()
	dl.renderHand()
}

func hitOrCall(p, dl *player, dc deck, hit bool) string {
	if hit {
		p.hit(dc)
		if p.score() > 21 {
			fmt.Printf("%v\n", p.score())
		} else {
			dl.renderHand()
			p.renderHand()
			hitOrCall(p, dl, dc, askHit())
		}
	}
	return strconv.Itoa(p.score())
}

func result(p, dl *player, dc deck) {
	for dls := 0; dls < 17; {
		dl.hit(dc)
		dls = dl.score()
	}
	p.renderHand()
	dl.renderHand()
	fmt.Print("RESULT: ")
	bj := 21
	if (p.score() > bj && dl.score() > bj) || (p.score() > bj && dl.score() <= bj) || (p.score() < dl.score()) {
		fmt.Println("YOU LOSE")
	} else if p.score() <= bj && p.score() == dl.score() {
		fmt.Println("DRAW")
	} else {
		fmt.Println("YOU WIN!")
	}
}

勉強になった点

Slice は Array への参照をもったデータ構造になっている

下のように slice をコピーし要素を変更すると、コピー元の slice の要素も変更されます。

	numbers := []int{1, 2, 3}
	// sliceをコピー
	numbers_2 := numbers
	// コピーしたsliceの要素を変更
	numbers_2[1] = 0
	// コピー元のsliceを確認
	fmt.Printf("%v",numbers)
	// => [1 0 3]

これは slice のデータ構造が配列のポインタであるから。(参照:スライスの実装
つまりnumbers_2 := numbersでは slice 自体は複製されますが、slice 内の array(ポインタ)の参照先は同じ場所になります。そのため、この二つの slice の要素の値は常に同じになります。
要素を新しいメモリ領域にコピーして使いたい場合は copy や append を使うことで実現できるそうです。

レシーバの呼び出し変換

ポインタレシーバは呼び出し時は(&x).m として暗黙的に変換が行われます。
そのためレシーバは値とポインタ、どちらの場合でも同一の記述方法で呼び出せます。

type numbers struct {
	x int
	y int
}

func (n *numbers)add() {
	fmt.Printf("result: %v\n",n.x + n.y)
}

func (n numbers)substract() {
	fmt.Printf("result: %v\n",n.x - n.y)
}

func main()  {
	n := numbers{x: 2, y:3}
	// レシーバが値、ポインタどちらでも同じ呼び出し方
	n.add()
	n.substract()
}

所感

実際にゲームすると、絵札をそのままの値でカウントしているため想像以上にバーストしやすくなっていたため、10 でカウントするようにしておけば良かったです。
あと、チップの概念を追加するとゲーム性も増し、実装にも工夫が必要になってくるのでより良いものになるかと思いました。

最後に

Belong はエンジニア募集中です。 弊社ではエンジニアのスキルアップを奨励しており、そのための環境が整っています。
今回のような個人的な開発であれば、毎週行われている Tech Talk というミーティングで熟練のエンジニアの方々からレビューを頂けます。また、「おすすめの技術書」や「今日の業務で理解したいと思った点」などをカジュアルに聞け、エンジニア同士の交流を大事にしています。
興味のある方は以下リンクから弊社のことを知って頂けたら幸いです。

HP: https://belong.co.jp/

エンジニアリングチームの紹介: https://entrancebook.belonginc.dev/

参考文献