14
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

オブジェクト指向を目指して、ブラックジャックを製作してみた

Last updated at Posted at 2018-08-14

はじめに

以前、Qiitaの記事を巡回していたところ
プログラミング入門者からの卒業試験は『ブラックジャック』を開発すべし
という記事を拝見しました。

ちょうど、以下の本2冊を読んだところでしたのでアウトプットしようと思い制作しました。

完成品

コマンドラインインターフェースで遊べるものを作りました。
ブラックジャック

ブラックジャックのルール

    [基本ルール]
    プレイヤーとディーラーの2人対戦。
    手札の点数の合計が21に近いほうが勝ち。
    点数の合計が21を超えたら負け。

    [カードの点数]
    A は 1 または 11
    2 ~ 10 は数値がそのまま点数
    J, Q, K は 10点

    [ゲームの流れ]
    1.プレイヤーとディーラーがそれぞれカードを2枚づつ引く。ただしディーラーのカードは1枚が非公開。
    2.プレイヤーは好きなだけカードを引く。ただし21を超えたらその時点で負け。
    3.ディーラーは点数が17点以上になるまで無条件でカード引き続ける。
    4.基本ルールにもとづき勝敗を判定する。

    [補足]
    ダブルダウンなし、スプリットなし、サレンダーなし、その他特殊ルールなし。

    [用語]
    *Hit*:カードをもう一枚引くこと。
    *Stand*:カードを引くのをやめ、その時点の手札の点数で勝負すること
    *Bust*:点数が21点を超えること。

エースを 1 点または 11 点として扱うことにチャレンジしました。

実際のソースコード

C# で書きました。

カードクラス

using System;

namespace BlackjackApp {
    // マーク
    enum Suit { diamond, club, heart, spade }

    class Card {
        // 1~13 のトランプの数字
        public int No { get; }

        // マーク
        public Suit Suit { get; }

        // 絵柄
        public String Rank =>
            No == 1 ? "A" :
            No == 11 ? "J" :
            No == 12 ? "Q" :
            No == 13 ? "K" :
            No.ToString();

        // 表か裏か
        public bool FaceUp { get; set; }

        public Card(Suit suit, int no) {
            if (no < 1 || 13 < no)
                throw new ArgumentOutOfRangeException(nameof(no));
            this.No = no;
            this.Suit = suit;
        }

        // カード表示用
        public override string ToString() {
            var suit = FaceUp ? Suit.ToString() : "???????";
            var rank = FaceUp ? Rank : "??";
            return $"[{suit,7}|{rank,2}]";
        }
    }
}

トランプカードの数値、マーク、絵柄は一度決まったら変えられないように読み取り専用としました。

山札クラス

using System;
using System.Collections.Generic;
using System.Linq;

namespace BlackjackApp {
    class Deck {
        // 内部的にカードのスタックとして保持する
        private Stack<Card> Cards { get; }

        public Deck() {
            var newCards = CreateCards();
            var shuffled = Shaffle(newCards);
            Cards = new Stack<Card>(shuffled);
        }

        // デッキの先頭からカードを一枚取り出す
        public Card Pop() => Cards.Pop();

        // 新しく52枚のカードを用意する
        private IEnumerable<Card> CreateCards() {
            var suits = GetSuitValues();
            var numbers = Enumerable.Range(1, 13);
            var cards = suits.SelectMany(suit =>
                numbers.Select(no => new Card(suit, no)));
            return cards;
        }

        // トランプのマークをすべて取得する
        private IEnumerable<Suit> GetSuitValues() =>
            Enum.GetValues(typeof(Suit)).Cast<Suit>();

        // カードをシャッフルする
        private IEnumerable<Card> Shaffle(IEnumerable<Card> cards) {
            var random = new Random();
            var shaffled = cards.OrderBy(_ => random.Next());
            return shaffled;
        }
    }
}

デッキはカードのコレクションになります。
コレクションに対する操作は複雑になりがちですので、
デッキクラスを作成しカードのコレクションをラップしました。

手札クラス

using System.Collections.Generic;
using System.Linq;

namespace BlackjackApp {
    class Hand {
        // 内部的にカードのリストとして保持する
        private IList<Card> Cards { get; }

        public Hand() => Cards = new List<Card>();

        // カードを1枚加える
        public void Add(Card card) => Cards.Add(card);

        // 点数を計算する
        public int ComputeScore() {
            var sum = Cards.Sum(card => card.No > 10 ? 10 : card.No);
            if (ContainsAce && sum <= 11)
                sum += 10;
            return sum;
        }

        // エースが含まれているか?
        private bool ContainsAce =>
            Cards.Any(card => card.No == 1);

        // 手札の内容表示用
        public override string ToString() =>
            string.Join(' ', Cards.Select(card => card.ToString()));

        public void FaceUpAll() {
            foreach (var card in Cards)
                card.FaceUp = true;
        }
    }
}

手札もカードのコレクションになります。
山札クラスと同様に手札クラスを作ってカードのコレクションをラップしました。

プレイヤー(ディーラー)クラス

using static System.Console;

namespace BlackjackApp {
    class Player {
        private Hand Hand { get; }
        private Deck Deck { get; }

        // 名前
        public string Name { get; }

        // 得点
        public int Score => Hand.ComputeScore();

        // バストしているか?
        public bool IsBust => Score > 21;

        public Player(Hand hand, Deck deck, string name) {
            Hand = hand;
            Deck = deck;
            Name = name;
        }

        // カードを1枚引く
        public void Take(bool faceUp = true) {
            var card = Deck.Pop();
            card.FaceUp = faceUp;
            ShowTookCard(card);
            Hand.Add(card);
        }

        // カードの表示
        private void ShowTookCard(Card card) =>
            WriteLine($"[{Name}] => {card}");

        // 手札を表示する
        public void ShowHand() {
            Hand.FaceUpAll();
            WriteLine($"[{Name}] => Hand: {Hand}");
            WriteLine($"[{Name}] => Score: {Score}");
        }
    }
}

プレイヤー(ディーラー)は手札と山札を持ち、処理のほとんどを委譲します。
手札と山札はコンストラクタを通じて外部から依存オブジェクトとして注入するようにしました。
山札はプレイヤーとディーラーで共有することになるので同一のインスタンスを注入します。
ShowTookCardメソッドはTakeメソッドのローカル関数にしたほうが良かったかもしれません。
プレイヤーとディーラーを同一のクラスとしましたが、いいネーミングが思いつきませんでした。

ゲームクラス


using System;
using static System.Console;

namespace BlackjackApp {
    class Game {
        Player Player { get; }
        Player Dealer { get; }

        public Game(Player player, Player dealer) {
            Player = player;
            Dealer = dealer;
        }

        public void Run() {
            WriteLine("<< Welcome to Blackjack!! >>\n");

            // プレイヤー最初のドロー
            Player.Take();
            Player.Take();
            Player.ShowHand();
            WriteLine();

            // ディーラー最初のドロー 
            Dealer.Take();
            Dealer.Take(faceUp: false);  // 2枚目は裏向き
            WriteLine();

            // ユーザーのターン
            WriteLine($"<< {Player.Name} turn! >>");
            while (ConfrimHitOrStand("Hit or Stand?", 'h', 's')) {
                Player.Take();
                Player.ShowHand();
                if (Player.IsBust) {
                    WriteLine($"{Player.Name} have over 21, {Player.Name} bust!");
                    WriteLine();
                    Lost();
                }
            }
            WriteLine();

            // ディーラーのターン
            WriteLine($"<< {Dealer.Name} turn! >>");
            while (Dealer.Score < 17) {
                Dealer.Take();
            }
            Dealer.ShowHand();
            if (Dealer.IsBust) {
                WriteLine($"{Dealer.Name} have over 21, {Dealer.Name} bust!");
                WriteLine();
                Won();
            }
            WriteLine();

            // 判定
            WriteLine("<< Result >>");
            Player.ShowHand();
            Dealer.ShowHand();
            if (Player.Score > Dealer.Score)
                Won();
            else if (Dealer.Score > Player.Score)
                Lost();
            else
                Drawn();
        }

        // ヒット・スタンド確認
        bool ConfrimHitOrStand(string message, char hit, char stand) {
            while (true) {
                Write($"{message} [{hit}/{stand}]");
                var key = ReadKey().KeyChar;
                WriteLine();
                if (key == hit)
                    return true;
                if (key == stand)
                    return false;
                WriteLine($"Invalid key. Please input {hit} or {stand}.");
            }
        }

        // 勝ち
        void Won() {
            WriteLine($"{Player.Name} won. Congrats!");
            End();
        }

        // 負け
        void Lost() {
            WriteLine($"{Player.Name} lost.");
            End();
        }

        // 引き分け
        void Drawn() {
            WriteLine("This game was drawn...");
            End();
        }

        // 終了
        void End() {
            WriteLine("To close, press any key.");
            ReadKey(intercept: true);
            Environment.Exit(0);
        }
    }
}

ゲームの流れは手続き型になってしまいました。
入力受付のインターフェースを定義したり、うまくゲームループを抽象化できればもっとスマートになったかもしれません。

エントリーポイント

namespace BlackjackApp {
    class Program {
        static void Main(string[] args) {
            var deck = new Deck();
            var player = new Player(new Hand(), deck, "Player");
            var dealer = new Player(new Hand(), deck, "Dealer");
            var game = new Game(player, dealer);
            game.Run();
        }
    }  
}

簡単なアプリケーションですので、DI コンテナーは使わず、
べた書きで直接、依存オブジェクトを注入しました。
やり方は簡単で、依存するインスタンスを明示的に作成し、集約するクラスに渡すだけです。

今後やってみたいこと

  • GUI アプリでも遊べるようにする。
    • System.Consoleに静的に結びついているのでそのまま GUI に移植できません。
    • ビュー用のinterfaceを定義して DI し、処理を委譲すれば CUI でも GUI でも簡単に切り替えられそうです。
  • ゲームのメイン処理を抽象化する。
    • 今のままだと可読性が高いとは言えない...
    • GUI だと大域脱出とかループとかいらないので逆にスッキリするかもしれないです。
14
20
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
14
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?