4
\$\begingroup\$

You were all so helpful with my Tc Tac Toe games in C. I was encouraged to put it up on GitHub and two very helpful users refactored it in ways that really taught me a thing or two. While I have switched focus to Python in anticipation of the upcoming 7DRL I was so happy about the way my C projects were reviewed that this has become one of my favorite forums. So now I want your help with something else:

Over the last week or so I've created a Blackjack game with Python 3.5.3. The logic was easy to do, especially with Python, but making a clean curses interface was more challenging. What I wound up doing was creating a blackjack game without curses to get the logic straight and then implementing curses in the form of a draw_screen() function that turned out to be a real monster. It is messy and repetitive compared to the rest of the code, and the function wound up being well over 100 lines long. In addition to that I'm still struggling to do things in the most "Pythonic" way, and even the behind-the-screen logic may not be implemented in a way that is ideal.

What I want help from you guys with is to help me refactor the draw_screen() function into something more manageable. Should I split it up into multiple functions? How best to do that? I would also appreciate any and all feedback regarding how I could make the non-curses logic of the game more "Pythonic."

I intend to make a Roguelike for the 7DRL event this year and I intend to do it with Python 3.5.3 and the curses library. It'll be harder than what I usually do but I'm looking forward to it. By helping me hammer out my fundamentals here you will help me to be better prepared for making a small Roguelike in the near future.

Note: the GREEN_TEXT and RED_TEXT color pair definitions were originally going to be used for the player's funds and for busting prompts, respectively. I haven't removed them because I intend to add that functionality myself very soon, if not today.

Here is the code itself:

"""
Project: Simple 21/Blackjack
File: twenty-one-curses.py
Date: 24 JAN 2019
Author: sgibber2018
Description: A simple implementation of 21/Blackjack using the terminal and python.
             Uses the curses library for character cell graphics. 
"""

import random
import curses

# init curses
stdscr = curses.initscr()
curses.cbreak()
curses.noecho()
curses.curs_set(False)
curses.start_color()

# init curses colors
curses.init_color(curses.COLOR_RED, 900, 0, 0)
curses.init_color(curses.COLOR_BLACK, 0, 0, 0)
curses.init_color(curses.COLOR_GREEN, 0, 900, 0)
curses.init_color(curses.COLOR_WHITE, 1000, 1000, 1000)
colors_dict = {"RED_CARD":1,
               "BLACK_CARD":2,
               "GREEN_TEXT":3,
               "RED_TEXT":4}
curses.init_pair(colors_dict.get("RED_CARD"), curses.COLOR_RED, curses.COLOR_WHITE)
curses.init_pair(colors_dict.get("BLACK_CARD"), curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(colors_dict.get("GREEN_TEXT"), curses.COLOR_GREEN, curses.COLOR_BLACK)
curses.init_pair(colors_dict.get("RED_TEXT"), curses.COLOR_RED, curses.COLOR_BLACK)


class Card:
    def __init__(self, suit, value):
        self.suit = suit
        self.value = value


def generate_deck():
    """
    Generate a list of Card objects to be used as a deck
    and return it
    """
    deck = []
    card_suits = ["D", "H", "S", "C"]
    card_nums_range = range(2, 11)
    card_faces = ["J", "Q", "K", "A"]
    for suits in range(len(card_suits)):
        for card_nums in card_nums_range:
            deck.append(Card(card_suits[suits], str(card_nums)))
        for card in range(len(card_faces)):
            deck.append(Card(card_suits[suits], card_faces[card]))
    random.shuffle(deck)
    return deck

def draw(hand, num_to_draw, deck):
    """
    takes a hand list and a number and draws that number
    of cards from the deck and places them in the 
    desired hand
    """
    for num_cards in range(num_to_draw):
        card = deck[-1]
        hand.append(card)
        deck.remove(card)

def count_hand(hand):
    """    
    Evaluates a hand and returns the value
    of its cards
    """
    card_values = {"2": 2, "3": 3, "4": 4, "5": 5,
                   "6": 6, "7": 7, "8":8, "9": 9,
                   "10":10, "J":10, "Q":10, "K":10}
    hand_sum = 0
    hand_suits = []
    for cards in hand:
        hand_suits.append(cards.value)
    if "A" in hand_suits:
        num_aces = 0
        for card_value in hand_suits:
            if card_value == "A":
                num_aces += 1
        for card in hand:
            if card.value != "A":
                hand_sum += card_values.get(card.value)
        if num_aces == 1:
            if hand_sum + 11 > 21:
                hand_sum += 1
            elif hand_sum + 11 <= 21:
                hand_sum += 11
        elif num_aces == 2:
            if hand_sum + 12 > 21:
                hand_sum += 2
            elif hand_sum + 12 <= 21:
                hand_sum += 12
        elif num_aces == 3:
            if hand_sum + 13 > 21:
                hand_sum += 3
            elif hand_sum + 13 <= 21:
                hand_sum += 13
        elif num_aces == 4:
            if hand_sum + 14 > 21:
                hand_sum += 4
            elif hand_sum + 14 <= 21:
                hand_sum += 14
    elif "A" not in hand_suits:
        for card in hand:
            hand_sum += card_values.get(card.value)
    return hand_sum

def player_hits(player_hand, stdscr):
    """
    Asks if player wants to hit
    """
    # get dimensions
    wsize = stdscr.getmaxyx()
    prompt_line = 16
    # lay out the strings
    prompt = "(H)it or (S)tay"
    prompt_hit = "Player has chosen to hit!"
    prompt_stay = "Player has chosen to stay!"
    prompt_wrong = "Invalid input! Try again..."
    # center the prompts
    prompt_x = wsize[1] // 2 - len(prompt) // 2
    prompt_hit_x = wsize[1] // 2 - len(prompt_hit) // 2
    prompt_stay_x = wsize[1] // 2 - len(prompt_stay) // 2
    prompt_wrong_x = wsize[1] // 2 - len(prompt_wrong) // 2
    # clear the entire prompt line
    clear_str = ""
    for char_cell in range(wsize[1]):
        clear_str += " "
    stdscr.addstr(prompt_line, 0, clear_str)
    # display the prompt
    stdscr.addstr(prompt_line, prompt_x, prompt)
    # get the input
    uinput = stdscr.getch()
    if uinput == 104 or uinput == 72:
        # print("Player has chosen to hit!")
        stdscr.addstr(prompt_line, 0, clear_str)
        stdscr.addstr(prompt_line, prompt_hit_x, prompt_hit)
        stdscr.getch()
        return True
    elif uinput == 83 or uinput == 115:
        # print("Player has chosen to stay!")
        stdscr.addstr(prompt_line, 0, clear_str)
        stdscr.addstr(prompt_line, prompt_stay_x, prompt_stay)
        stdscr.getch()
        return False
    else:
        # print("Invalid input! Try again...")
        stdscr.addstr(prompt_line, 0, clear_str)
        stdscr.addstr(prompt_line, prompt_wrong_x, prompt_wrong)
        stdscr.getch()
        player_hits(player_hand)

def prompt(string, stdscr):
    """
    Takes a string, clears the prompt line, and places the
    string on the prompt line
    """
    wsize = stdscr.getmaxyx()
    prompt_line = 16
    prompt_clear = ""
    for char_cell in range(wsize[1]):
        prompt_clear += " "
    stdscr.addstr(prompt_line, 0, prompt_clear)
    centered_x = wsize[1] // 2 - len(string) // 2
    stdscr.addstr(prompt_line, centered_x, string)
    stdscr.getch()

def is_busted(hand):
    """
    Checks a hand and if it is busted, returns True
    """
    card_values = {"2": 2, "3": 3, "4": 4, "5": 5,
                   "6": 6, "7": 7, "8":8, "9": 9,
                   "10":10, "J":10, "Q":10, "K":10}
    hand_sum = 0
    hand_suits = []
    for cards in hand:
        hand_suits.append(cards.value)
    if "A" in hand_suits:
        num_aces = 0
        for card_value in hand_suits:
            if card_value == "A":
                num_aces += 1
        for card in hand:
            if card.value != "A":
                hand_sum += card_values.get(card.value)
        if num_aces == 1:
            if hand_sum + 11 > 21:
                hand_sum += 1
            elif hand_sum + 11 <= 21:
                hand_sum += 11
        elif num_aces == 2:
            if hand_sum + 12 > 21:
                hand_sum += 2
            elif hand_sum + 12 <= 21:
                hand_sum += 12
        elif num_aces == 3:
            if hand_sum + 13 > 21:
                hand_sum += 3
            elif hand_sum + 13 <= 21:
                hand_sum += 13
        elif num_aces == 4:
            if hand_sum + 14 > 21:
                hand_sum += 4
            elif hand_sum + 14 <= 21:
                hand_sum += 14
    elif "A" not in hand_suits:
        for card in hand:
            hand_sum += card_values.get(card.value)
    if hand_sum > 21:
        return True
    else:
        return False


def game_not_over(player_funds, turn_num):
    """
    Checks to see if the game is over.
    Returns True if game not over.
    Prints game over if game is over, then returns False
    """
    if player_funds <= 0:
        prompt("Player loses in " + str(turn_num) + " turns!", stdscr)
        return False
    elif player_funds > 0:
        return True

def compare_hands(dealer_hand, player_hand):
    """
    Checks to see which hand is the winner
    returns "dealer" or "player" as a result
    In case of tie, returns "dealer"
    """
    player_score = count_hand(player_hand)
    dealer_score = count_hand(dealer_hand)
    if player_score > dealer_score:
        return "player"
    elif dealer_score >= player_score:
        return "dealer"

def dealer_hits(dealer_hand):
    """
    Counts the dealer hand and returns true
    if under 17
    """
    count = count_hand(dealer_hand)
    if count < 17:
        prompt("Dealer hits!", stdscr)
        return True
    else: 
        prompt("Dealer Stays!", stdscr)
        return False

def draw_screen(stdscr, dealer_hand, player_hand, turn_num, player_funds, dealer_flipped=False):
    """
    Draws the entire game status on to the screen
    including a visual representation of the cards in play.
    Will be centered in final version.
    """
    # clear screen
    stdscr.clear()
    # get dimensions
    wsize = stdscr.getmaxyx()
    display_height = 17
    display_width = 36
    # get the strings
    funds_str = str("Player Funds: " + str(player_funds))
    turn_str = str("Turn Number: " + str(turn_num))
    player_score_str = str("Player: " + str(count_hand(player_hand)))
    # dealer score string depends on whether dealer_flipped is flagged
    if dealer_flipped:
        dealer_score_str = str("Dealer: " + str(count_hand(dealer_hand)))
    if not dealer_flipped:
        flipped_dealer_hand = []
        for card in range(len(dealer_hand)):
            if card != 0:
                flipped_dealer_hand.append(dealer_hand[card])
        dealer_score_str = str("Dealer Visible: " + str(count_hand(flipped_dealer_hand)))
    # place the strings in their appropriate places
    dealer_str_coords = (0, 1)
    player_str_coords = (8, 1)
    funds_str_coords = (15, 1)
    turn_str_coords = (15, 20)
    stdscr.addstr(dealer_str_coords[0], dealer_str_coords[1], dealer_score_str)
    stdscr.addstr(player_str_coords[0], player_str_coords[1], player_score_str)
    stdscr.addstr(funds_str_coords[0], funds_str_coords[1], funds_str)
    stdscr.addstr(turn_str_coords[0], turn_str_coords[1], turn_str)
    # place the cards:
    # create lists of tuples with the x and y coords or each symbol on each card
    # List of lists of tuples: 
    #   Outer list = hand.
    #   Inner list = card
    #   Sets = (top-left suit, central value, bottom-right suit)
    # called with something like sym = dealer_hand_coords[0][0] for top-left symbol of first card in hand
    # first tuple doubles as a top-left coordinate for the blank card rects
    dealer_hand_coords = [[(2, 1), (4, 2), (6, 3)],
                          [(2, 5), (4, 6), (6, 7)],
                          [(2, 9), (4, 10), (6, 11)],
                          [(2, 13), (4, 14), (6, 15)],
                          [(2, 17), (4, 18), (6, 19)],
                          [(2, 21), (4, 22), (6, 23)],
                          [(2, 25), (4, 26), (6, 27)],
                          [(2, 29), (4, 30), (6, 31)],
                          [(2, 33), (4, 34), (6, 35)]]

    player_hand_coords = [[(9, 1), (11, 2), (13, 3)],
                          [(9, 5), (11, 6), (13, 7)],
                          [(9, 9), (11, 10), (13, 11)],
                          [(9, 13), (11, 14), (13, 15)],
                          [(9, 17), (11, 18), (13, 19)],
                          [(9, 21), (11, 22), (13, 23)],
                          [(9, 25), (11, 26), (13, 27)],
                          [(9, 29), (11, 30), (13, 31)],
                          [(9, 33), (11, 34), (13, 35)]]

    # NOTE: Re-Factor this into some more DRY-compliant code
    # NOTE: Re-Factor into multiple smaller functions that are easier for others
    #       to follow along with!
    # player hand
    for card in range(len(player_hand)):
        # for each card in the hand
        value = player_hand[card].value
        suit = player_hand[card].suit
        if suit == "H" or suit == "D":
            color = colors_dict.get("RED_CARD")
        elif suit == "C" or suit == "S":
            color = colors_dict.get("BLACK_CARD")

        # place the blank card rect
        card_height = 5
        card_width = 3
        for cell_y in range(player_hand_coords[card][0][0], player_hand_coords[card][0][0] + card_height):
            for cell_x in range(player_hand_coords[card][0][1], player_hand_coords[card][0][1] + card_width):
                stdscr.addstr(cell_y, cell_x, " ", curses.color_pair(color))

        # place the symbols
        # place two suit symbols and a value symbol
        stdscr.addstr(player_hand_coords[card][0][0], player_hand_coords[card][0][1], suit, curses.color_pair(color))
        stdscr.addstr(player_hand_coords[card][1][0], player_hand_coords[card][1][1], value, curses.color_pair(color))
        stdscr.addstr(player_hand_coords[card][2][0], player_hand_coords[card][2][1], suit, curses.color_pair(color))

    # dealer hand
    if dealer_flipped:
        for card in range(len(dealer_hand)):
            # for each card in the hand
            value = dealer_hand[card].value
            suit = dealer_hand[card].suit
            if suit == "H" or suit == "D":
                color = colors_dict.get("RED_CARD")
            elif suit == "C" or suit == "S":
                color = colors_dict.get("BLACK_CARD")

            # place the blank card rect
            card_height = 5
            card_width = 3
            for cell_y in range(dealer_hand_coords[card][0][0], dealer_hand_coords[card][0][0] + card_height):
                for cell_x in range(dealer_hand_coords[card][0][1], dealer_hand_coords[card][0][1] + card_width):
                    stdscr.addstr(cell_y, cell_x, " ", curses.color_pair(color))

            # place the symbols
            # place two suit symbols and a value symbol
            stdscr.addstr(dealer_hand_coords[card][0][0], dealer_hand_coords[card][0][1], suit, curses.color_pair(color))
            stdscr.addstr(dealer_hand_coords[card][1][0], dealer_hand_coords[card][1][1], value, curses.color_pair(color))
            stdscr.addstr(dealer_hand_coords[card][2][0], dealer_hand_coords[card][2][1], suit, curses.color_pair(color))

    if not dealer_flipped:
        for card in range(len(dealer_hand)):
            # for each card in the hand
            value = dealer_hand[card].value
            suit = dealer_hand[card].suit
            if suit == "H" or suit == "D":
                color = colors_dict.get("RED_CARD")
            elif suit == "C" or suit == "S":
                color = colors_dict.get("BLACK_CARD")

            # place the blank card rect
            card_height = 5
            card_width = 3
            for cell_y in range(dealer_hand_coords[card][0][0], dealer_hand_coords[card][0][0] + card_height):
                for cell_x in range(dealer_hand_coords[card][0][1], dealer_hand_coords[card][0][1] + card_width):
                    stdscr.addstr(cell_y, cell_x, " ", curses.color_pair(color))

            # place the symbols
            # place two suit symbols and a value symbol
            if card != 0:
                stdscr.addstr(dealer_hand_coords[card][0][0], dealer_hand_coords[card][0][1], suit, curses.color_pair(color))
                stdscr.addstr(dealer_hand_coords[card][1][0], dealer_hand_coords[card][1][1], value, curses.color_pair(color))
                stdscr.addstr(dealer_hand_coords[card][2][0], dealer_hand_coords[card][2][1], suit, curses.color_pair(color))
    stdscr.refresh()

def main(stdscr):
    try:
        # bet amount
        bet = 100
        # starting funds
        player_funds = 1000
        turn_num = 1
        while game_not_over(player_funds, turn_num):
            # while the player has funds left to bet
            # generate a new deck
            deck = generate_deck()
            dealer_hand = []
            player_hand = []
            # draw two cards for each player
            draw(dealer_hand, 2, deck)
            draw(player_hand, 2, deck)
            # take the player's bet
            player_funds -= bet
            winner = None
            player_hitting = True
            while player_hitting:
                # while the player is deciding to hit or stay:
                # draw the screen with curses
                draw_screen(stdscr, dealer_hand, player_hand, turn_num, player_funds)
                if player_hits(player_hand, stdscr):
                    # if the player chooses to hit:
                    # draw a card
                    draw(player_hand, 1, deck) 
                    if is_busted(player_hand):
                        # If the player busts:
                        # draw the screen again
                        draw_screen(stdscr, dealer_hand, player_hand, turn_num, player_funds)
                        # prompt that the player has busted
                        prompt("Player Busted!", stdscr)
                        player_hitting = False
                        winner = "dealer"
                else:
                    # end the loop if the player chooses to stay
                    player_hitting = False
            if not is_busted(player_hand):
                # If the player has stayed and the player has not busted:
                dealer_hitting = True
                while dealer_hitting:
                    # while the dealer is choosing to hit or stay:
                    # draw the screen with curses
                    draw_screen(stdscr, dealer_hand, player_hand, turn_num, player_funds, dealer_flipped=True)
                    if dealer_hits(dealer_hand):
                        # If the dealer chooses to hit:
                        # dealer draws a card
                        draw(dealer_hand, 1, deck)
                        if is_busted(dealer_hand):
                            # If the dealer busts:
                            # draw the screen with curses
                            draw_screen(stdscr, dealer_hand, player_hand, turn_num, player_funds, dealer_flipped=True)
                            # prompt that the dealer has busted
                            prompt("Dealer Busted!", stdscr)
                            dealer_hitting = False
                            winner = "player"
                            # reward the player with double their bet
                            player_funds += bet * 2
                    else:
                        # if the dealer busts, break the loop
                        dealer_hitting = False
            if not is_busted(dealer_hand):
                if not is_busted(player_hand):
                    # If neither player has busted and both have stayed:
                    # draw the screen with curses
                    draw_screen(stdscr, dealer_hand, player_hand, turn_num, player_funds, dealer_flipped=True)
                    # get the winning hand
                    winner = compare_hands(dealer_hand, player_hand)
                    # prompt the winner
                    prompt(str(winner + " Wins!"), stdscr)
                    if winner == "player":
                        # if the player wins, reward them with double their bet
                        player_funds += bet * 2
            # increase turn num
            turn_num += 1
    finally:
        # end curses window on error or exit
        curses.endwin()


if __name__ == "__main__":
main(stdscr)

And here is a link to the GitHub page, for those who want to contribute more directly.

As ever, anyone who finds this useful is more than welcome to do whatever they want with it. I worked hard on it, but it was just a practice project. If it helps someone else, by all means go for it.

\$\endgroup\$
  • 2
    \$\begingroup\$ While it's great that a review enabled you to improve your code, please do not update the code in your question to incorporate feedback from answers. Doing so goes against the Question + Answer style of Code Review, as it unfortunately invalidates the existing review(s). This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers for ways to announce your new code. \$\endgroup\$ – Zeta Jan 26 at 7:53
  • \$\begingroup\$ Aw shucks my bad. \$\endgroup\$ – some_guy632 Jan 26 at 8:04
  • 1
    \$\begingroup\$ Oh wow I was in the middle of re-editing it to add in the previous version before I realized a mod had already done it for me. Thank you and good looking out. The updated version can be seen on the GitHub page, where I have also added some credit to vnp. Although I only implemented the easiest of their suggestions at the moment I will be implementing more as well. Thanks for bearing with me. \$\endgroup\$ – some_guy632 Jan 26 at 8:13
  • 1
    \$\begingroup\$ Ah, sorry, I forgot to mention that I've rolled your changes back. By the way, when you work with git, you can also add some more information in the commit message. Just drop the -m "<msg>" from your command line (or add --edit). This enables you to credit commits explicitly to others. Best of luck with 21. \$\endgroup\$ – Zeta Jan 26 at 8:54
2
\$\begingroup\$
  • You are not implementing standard (aka Las Vegas) rules. According to the standard rules, a player's hand of an Ace and a honeur forms a blackjack, and must be disclosed immediately. I am not even talking about the split and insurance rules.

  • You don't give the player an opportunity to wrap up, collect her fortune and go home. The game continues until she is stripped off completely.

  • I am afraid there are more monsters than you are aware of. To begin with, I don't see a clean MVC separation. A litmus test is to evaluate efforts required to port this code from curses to, say, Tk.

    Besides, draw_screen has no business to count hands' values, or to be concerned with the dealers' open card. This information shall be computed by the model, and passed to view in an appropriate form.

  • count_hand is suspiciously similar to is_busted. I expect

    def is_busted(....):
        return count_hand(....) > 21
    
  • count_hand is also overcomplicated. Consider

    def count_hand(hand):
        aces = 0
        hand_sum = 0
        for card in hand:
            if card.is_ace():
                aces += 1 # Initially count all aces as 1
            hand_sum += card.value
        # Now try to assign come aces an extra 10 points
        while aces > 0 and hand_sum <= 11:
            aces -= 1
            hand_sum += 10
        return hand_sum
    

    Notice how having card itself an instance of the class helps.

\$\endgroup\$
  • \$\begingroup\$ Can you give an example of the MVC problem? I never considered porting it to Tk or any other display library. Draw screen counts hand values in order to display the score, and whether the dealer's card is flipped heavily influences how to draw the screen, and the player's response. Can you elaborate? Thanks for taking the time to review this but I'll benefit much more from you being more specific. I'm still a relative novice after all. As for the ruleset I wasn't trying to accurately mimic casino rules but you're right about giving the player a chance to walk away. Maybe bet adjustments too! \$\endgroup\$ – some_guy632 Jan 26 at 6:45
  • \$\begingroup\$ As for count_hand() and is_busted(), you're spot on. They're almost the same function and definitely could be combined. I also like the idea of adding an is_ace() method. Very good call. Thanks! \$\endgroup\$ – some_guy632 Jan 26 at 6:50
  • \$\begingroup\$ you may be interested to know that in the GitHub version I have implemented all of your suggestions except for the instant-blackjack. You seem to know a lot about best practices, so could I get you to expand on how I can better re-factor my draw_screen() function? I could blindly break it up into smaller functions, sure, but I get the feeling you have some specific pitfalls to share and I'm eager to soak up expert knowledge. \$\endgroup\$ – some_guy632 Jan 26 at 9:21

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.