12
\$\begingroup\$

I attempted to implement a simple Gomoku game (connect 5) in pygame and wanted to get some help. So far, the basic features are working fine with 2 players going against each other.

Entire Code

import numpy as np
import pygame
import sys
import math

# initialize the pygame program
pygame.init()

# static variables
ROW_COUNT = 15
COL_COUNT = 15

# define screen size
BLOCKSIZE = 50 # individual grid
S_WIDTH = COL_COUNT * BLOCKSIZE # screen width
S_HEIGHT = ROW_COUNT * BLOCKSIZE # screen height
PADDING_RIGHT = 200 # for game menu
SCREENSIZE = (S_WIDTH + PADDING_RIGHT,S_HEIGHT)
RADIUS = 20 # game piece radius

# colors
BLACK = (0,0,0)
WHITE = (255,255,255)
BROWN = (205,128,0)

# create a board array
def create_board(row, col):
    board = np.zeros((row,col))
    return board

# draw a board in pygame window
def draw_board(screen):
    for x in range(0,S_WIDTH,BLOCKSIZE):
        for y in range(0,S_HEIGHT,BLOCKSIZE):
            rect = pygame.Rect(x, y, BLOCKSIZE, BLOCKSIZE)
            pygame.draw.rect(screen,BROWN,rect)

    # draw inner grid lines
    # draw vertical lines
    for x in range(BLOCKSIZE // 2, S_WIDTH - BLOCKSIZE // 2 + BLOCKSIZE, BLOCKSIZE):
        line_start = (x, BLOCKSIZE // 2)
        line_end = (x,S_HEIGHT-BLOCKSIZE // 2)
        pygame.draw.line(screen, BLACK, line_start,line_end,2)

    # draw horizontal lines
    for y in range(BLOCKSIZE // 2, S_HEIGHT - BLOCKSIZE // 2 + BLOCKSIZE, BLOCKSIZE):
        line_start = (BLOCKSIZE // 2,y)
        line_end = (S_WIDTH-BLOCKSIZE // 2,y)
        pygame.draw.line(screen, BLACK, line_start,line_end,2)
    pygame.display.update()

# drop a piece
def drop_piece(board, row, col, piece):
    board[row][col] = piece

# draw a piece on board
def draw_piece(screen,board):
    # draw game pieces at mouse location
    for x in range(COL_COUNT):
        for y in range(ROW_COUNT):
            circle_pos = (x * BLOCKSIZE + BLOCKSIZE//2, y * BLOCKSIZE + BLOCKSIZE//2)
            if board[y][x] == 1:
                pygame.draw.circle(screen, BLACK, circle_pos, RADIUS)
            elif board[y][x] == 2:
                pygame.draw.circle(screen, WHITE, circle_pos, RADIUS)
    pygame.display.update()

# check if it is a valid location
def is_valid_loc(board, row, col):
    return board[row][col] == 0

# victory decision
def who_wins(board, piece):
    # check for horizontal win
    for c in range(COL_COUNT - 4):
        for r in range(ROW_COUNT):
            if board[r][c] == piece and board[r][c+1] == piece and board[r][c+2] == piece and board[r][c+3] == piece\
                and board[r][c+4] == piece:
                return True

    # check for vertical win
    for c in range(COL_COUNT):
        for r in range(ROW_COUNT-4):
            if board[r][c] == piece and board[r+1][c] == piece and board[r+2][c] == piece and board[r+3][c] == piece\
                and board[r+4][c] == piece:
                return True

    # check for positively sloped diagonal wih
    for c in range(COL_COUNT-4):
        for r in range(4,ROW_COUNT):
            if board[r][c] == piece and board[r-1][c+1] == piece and board[r-2][c+2] == piece and board[r-3][c+3] == piece\
                and board[r-4][c+4] == piece:
                return True

    # check for negatively sloped diagonal win
    for c in range(COL_COUNT-4):
        for r in range(ROW_COUNT-4):
            if board[r][c] == piece and board[r+1][c+1] == piece and board[r+2][c+2] == piece and board[r+3][c+3] == piece\
                and board[r+4][c+4] == piece:
                return True

def main():
    # game variables
    game_over = False
    turn = 0 # turn == 0 for player 1, turn == 1 for player 2
    piece_1 = 1 # black
    piece_2 = 2 # white

    # FPS
    FPS = 60
    frames_per_sec = pygame.time.Clock()

    # board 2D array
    board = create_board(ROW_COUNT,COL_COUNT)
    print(board)

    # game screen
    SCREEN = pygame.display.set_mode(SCREENSIZE)
    SCREEN.fill(WHITE)
    pygame.display.set_caption('Gomoku (Connet 5)')
    # icon = pygame.image.load('icon.png')
    # pygame.display.set_icon(icon)

    # font
    my_font = pygame.font.Font('freesansbold.ttf', 32)

    # text message
    label_1 = my_font.render('Black wins!', True, WHITE, BLACK)
    label_2 = my_font.render('White wins!', True, WHITE, BLACK)

    # display the screen
    draw_board(SCREEN)

    # game loop
    while not game_over:

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                sys.exit()

            elif event.type == pygame.MOUSEBUTTONDOWN:
                x_pos = event.pos[0]
                y_pos = event.pos[1]

                col = int(math.floor(x_pos / BLOCKSIZE))
                row = int(math.floor(y_pos / BLOCKSIZE))

                # turn decision, if black(1)/white(2) piece already placed, go back to the previous turn
                if board[row][col] == 1:
                    turn = 0
                if board[row][col] == 2:
                    turn = 1

                # Ask for Player 1 move
                if turn == 0:
                    # check if its a valid location then drop a piece
                    if is_valid_loc(board, row, col):
                        drop_piece(board, row, col, piece_1)
                        draw_piece(SCREEN,board)

                        if who_wins(board,piece_1):
                            print('Black wins!')
                            SCREEN.blit(label_1, (280,50))
                            pygame.display.update()
                            game_over = True

                # Ask for Player 2 move
                else:
                    # check if its a valid location then drop a piece
                    if is_valid_loc(board, row, col):
                        drop_piece(board, row, col, piece_2)
                        draw_piece(SCREEN,board)

                        if who_wins(board,piece_2):
                            print('White wins!')
                            SCREEN.blit(label_2, (280,50))
                            pygame.display.update()
                            game_over = True

                print(board)

                # increment turn
                turn += 1
                turn = turn % 2

                if game_over:
                    pygame.time.wait(4000)

        frames_per_sec.tick(FPS)

if __name__ == '__main__':
    main()

Game play screenshots: enter image description here

enter image description here

Comment

The game seems to run fine. I moved some of my code into main() so that I can create a restart feature for the next step.

Also some of the additional features I'm thinking about creating:

  • undo function: undoes a previous move
  • redo function: restores a piece deleted by the undo() function
  • leave a mark on the current piece. example: enter image description here

Any criticism/help are very welcome!

\$\endgroup\$
0
10
\$\begingroup\$

On the whole, this game works nicely and the design decisions make sense for the size and purpose of the program. Variable names are clear, there's some separation between GUI and game logic and you've made an effort to use functions. Some of my suggestions might be premature, but if you plan to extend the program to add a feature like an AI or undo/redo moves, I'd suggest a redesign.

Crash

On my screen, a white bar renders on the right side of the game. If you click it, drop_a_piece raises an uncaught IndexError because it has no bounds checking.

Avoid unnecessary comments

Many of the comments are redundant:

# drop a piece
def drop_piece(board, row, col, piece):
    board[row][col] = piece

I can tell from the function name that this drops a piece. Comments like this feel insulting to the reader's intelligence. If you want to document your functions, the Python way is to use docstrings and (optionally) doctests to enforce contracts.

Occasional # comments are OK, as long as they actually offer deep insight into the code that isn't necessarily apparent otherwise. You have a few of these, like

# turn decision, if black(1)/white(2) piece already placed, go back to the previous turn

...but make sure these comments aren't crutches to make up for unnecessarily confusing code.

Avoid comments as a substitute for proper code abstractions

Other comments are used in place of namespaces or functions, real program structures that organize logic.

For example, something as simple as:

# colors
BLACK = (0,0,0)
WHITE = (255,255,255)
BROWN = (205,128,0)

could be:

class Colors:
    BLACK = 0, 0, 0
    WHITE = 255, 255, 255
    BROWN = 205, 128, 0

An example of a comment that seems to be trying to denote a function is in who_wins

# check for vertical win
for c in range(COL_COUNT):
# ... more code ...

which might as well be broken out into a function called check_vertical_win. After following this to its conclusion, who_wins would look like:

def who_wins(board, piece):
    return (
        check_left_diagonal_win(board, piece) or
        check_right_diagonal_win(board, piece) or
        check_vertical_win(board, piece) or
        check_horizontal_win(board, piece)
    )

The problem now is that board and piece have to be passed around through multiple layers of these C-style, non-OOP functions, which I'll discuss later.

Go all-in or all-out with NumPy

If you're bringing in NumPy, you might as well use it to its full potential! The only NumPy call in the whole code is board = np.zeros((row,col)). This seems like a missed opportunity -- you should be able to vectorize many of your operations and write idiomatic NumPy code. This might seem premature, but if you were to add an AI that needs to traverse the game tree, NumPy can offer potentially massive efficiency boosts. Readability would hopefully improve.

If you're not going to use NumPy as more than a glorified 2d list print formatter, I'd drop the dependency and add a few-line pretty print helper, or remove the print entirely since it's a GUI game.

Go all-in or all-out on making it a module

The code has the "driver" code that's typical of modules that expect to be imported as a distinct package:

if __name__ == '__main__':
    main()

but due to all of the scattershot variables and functions outside of main, it's not a very convenient module that would be usable if imported, so this seems like a false promise.

main should be really simple -- that's your client using the code as a black box library (think of it like how you use NumPy), so you're asking them to implement 80 lines in order to use your functions to implement a Gomoku game. Clearly, far too much of the game logic has been dumped into main. Ideally, you'd get that down to a couple lines or so that work as a black box, possibly with some configutation knobs, something like:

if __name__ == '__main__':
    game = Gomoku(some_config)
    game.play()

Creating a class to operate on board state, or at least using modules and namespacing your functions and data can help solve the problem of having to pass board to your functions and break encapsulation constantly to read globals.

I'd prefer a Gomoku class that runs the GUI and game loop which is the client of a GomokuPosition class that encompasses the board, ply and methods that operate on game state.

Reduce cyclomatic complexity

You do a pretty good job of using fairly small and single-ish responsibility functions up until main, when complexity blows up. There are 13 branches and loops that nest up to 6 deep:

while not game_over:
   for event in pygame.event.get():
       elif event.type == pygame.MOUSEBUTTONDOWN:
           if turn == 0:
               if is_valid_loc(board, row, col):
                   if who_wins(board,piece_1):
                       # you may ask yourself, "well... how did I get here?"

This is difficult to reason about and should be multiple, single-purpose functions, keeping UI and game logic as separate as possible (helpful if you plan to add AI).

Keep nesting to no more than 2-3 blocks deep and keep if/else chains to no more than 2-3 branches long, otherwise the function is too complex.

Avoid long lines

I have to horizontally scroll to read lines like:

if board[r][c] == piece and board[r+1][c+1] == piece and board[r+2][c+2] == piece and board[r+3][c+3] == piece\
    and board[r+4][c+4] == piece:

Please stick to a width of 80 characters. Checks like this could be broken out into functions or be broken into multiple lines with parentheses (almost always avoid the backslash to continue a line):

if (
    board[r][c] == piece and 
    board[r+1][c+1] == piece and  
    board[r+2][c+2] == piece and 
    board[r+3][c+3] == piece and
    board[r+4][c+4] == piece
):

(This uses the "sad face" style advocated by Black).

Better yet, there's a pattern here you can extract and exploit with NumPy:

if np.all(board.diagonal(r)[c:c+5] == piece):

This isn't just a matter of elegance, iterating at a higher level of abstraction in place of heavy reliance on low-level indexing/range() should reduce bugs. A codebase with tons of verbose manual indexing tends to harbor subtle off-by-one, copy-paste and typo-style errors and can be hard to trust and validate.

If you're as inept at NumPy as I am, I can usually find the right vector by searching for stuff like "numpy diagonal slice". 99% of the time there's a builtin or a one-liner that puts my imperative Python for loop code to shame. If you decide against using NumPy, you can still use slices (and often itertools) to try to minimize error-prone and non-idiomatic range calls and indexes.

DRY out similar code

The chunks of code # Ask for Player 1 move and # Ask for Player 2 move are pretty much the same with obvious parameters: piece_num and on_win_message. Code like this is ready for a function. After that refactor, they still bake together game logic and presentation/UI, so I'd tease those two apart.

Use intermediate variables to simplify code

Consider:

for x in range(BLOCKSIZE // 2, S_WIDTH - BLOCKSIZE // 2 + BLOCKSIZE, BLOCKSIZE):
    line_start = (x, BLOCKSIZE // 2)
    line_end = (x,S_HEIGHT-BLOCKSIZE // 2)
    pygame.draw.line(screen, BLACK, line_start,line_end,2)

Assuming we won't use NumPy here, BLOCKSIZE // 2 is done repeatedly and the code is generally screamy/COBOL-y and hard on the eyes. Even using two local variables offers a bit of relief, gaining horizontal readability for a few extra vertical lines:

half = BLOCKSIZE // 2
end = S_WIDTH - half + BLOCKSIZE

for x in range(half, end, BLOCKSIZE):
    line_start = x, half
    line_end = x, S_HEIGHT - half
    pygame.draw.line(screen, BLACK, line_start, line_end, width=2)

I snuck in width=2, because it's hard to tell what that parameter means otherwise. It's generally a great idea to use named parameters.

Avoid magic variables/literals

The code

piece_1 = 1 # black
piece_2 = 2 # white

looks like an attempt at an enumeration but why not BLACK = 0 and WHITE = 1? An empty square is a 0, so on further inspection this makes sense, but I'm not sure these pieces need to exist; the only places in the code these are used are:

drop_piece(board, row, col, piece_1)
draw_piece(SCREEN,board)

if who_wins(board,piece_1):

and another location, where they're substituted in favor of magic constants:

if board[row][col] == 1: # <-- really piece_1
    turn = 0
if board[row][col] == 2: # <-- really piece_2
    turn = 1

All of this seems a little ad-hoc.

Similarly, the hardcoded literal 4 appears many times inside of who_wins. It's not clear why this wasn't made a constant as was done for the other board configuration numbers.

Simplify win checking

There's no need to scan the entire board to check a win. You can count in 4 directions from the last move, counting matching cells outward until you hit the desired total on one of the directions.

You can use a count of the number of turns taken so far to skip the entire win check if there are simply not enough pieces on the board for a win to be possible. For almost no expense, you get a massive performance boost if you add an AI that searches the tree later.

Minor nitpicks

  • In col = int(math.floor(x_pos / BLOCKSIZE)), math.floor already returns an int so you can skip the extra call. Or skip both and use the floor division operator //.

  • Unpacking can be elegant:

    x_pos = event.pos[0]
    y_pos = event.pos[1]
    

    goes to x_pos, y_pos = event.pos

  • who_wins is a bit oddly-worded because nobody may have won. I'd prefer is_won, check_win or just_won if it only checks for a win on the last move as proposed above.

  • game_over isn't necessary; you could break the loop (or return if it was a function) and run the after-game delay separately.

  • 2-player turn-based strategy games usually use ply instead of turn. You can keep incrementing ply and use its parity (ply & 1 or ply % 2 == 0) to determine side. If you do use turn as a single bit that flips back and forth, I'd prefer turn ^= 1 over

    turn += 1
    turn = turn % 2
    
  • Be consistent with spacing:

    pygame.draw.line(screen, BLACK, line_start,line_end,2)
    

    should be

    pygame.draw.line(screen, BLACK, line_start, line_end, 2)
    
  • Since draws are possible when the board fills up without either side making 5 in a row, you might want to handle this in the UI and game logic.

  • Pluralization: draw_piece really draws all the pieces, so it should be called draw_pieces.

  • Caption typo: 'Gomoku (Connet 5)'. Connect 4 is a drop connection game, so Gomoku is more like generalized mxnxk tic-tac-toe but I'm just being pedantic.

  • Alphabetize imports.

Possible rewrite

Here's the GomokuPosition module:

class GomokuPosition:
    dirs = (
        ((0, -1), (0, 1)), 
        ((1, 0), (-1, 0)),
        ((1, 1), (-1, -1)),
        ((1, -1), (-1, 1)),
    )

    def __init__(self, rows, cols, n_to_win, players="wb", blank="."):
        self.ply = 0
        self.rows = rows
        self.cols = cols
        self.last_move = None
        self.n_to_win = n_to_win
        self.boards = [[[0] * cols for _ in range(rows)] for i in range(2)]
        self.players = players
        self.blank = blank

    def board(self, row=None, col=None):
        if row is None and col is None:
            return self.boards[self.ply&1]
        elif col is None:
            return self.boards[self.ply&1][row]

        return self.boards[self.ply&1][row][col]

    def move(self, row, col):
        if self.in_bounds(row, col) and self.is_empty(row, col):
            self.board(row)[col] = 1
            self.ply += 1
            self.last_move = row, col
            return True

        return False

    def is_empty(self, row, col):
        return not any(board[row][col] for board in self.boards)

    def in_bounds(self, y, x):
        return y >= 0 and y < self.rows and x >= 0 and x < self.cols

    def count_from_last_move(self, dy, dx):
        if not self.last_move:
            return 0

        last_board = self.boards[(self.ply-1)&1]
        y, x = self.last_move
        run = 0

        while self.in_bounds(y, x) and last_board[y][x]:
            run += 1
            x += dx
            y += dy
        
        return run

    def just_won(self):
        return self.ply >= self.n_to_win * 2 - 1 and any(
            (self.count_from_last_move(*x) + 
             self.count_from_last_move(*y) - 1 >= self.n_to_win)
            for x, y in self.dirs
        )
        
    def is_draw(self):
        return self.ply >= self.rows * self.cols and not self.just_won()

    def last_player(self):
        if self.ply < 1:
            raise IndexError("no moves have been made")

        return self.players[(self.ply-1)&1]

    def char_for_cell(self, row, col):
        for i, char in enumerate(self.players):
            if self.boards[i][row][col]:
                return char
        
        return self.blank

    def to_grid(self):
        return [
            [self.char_for_cell(row, col) for col in range(self.cols)]
            for row in range(self.rows)
        ]

    def __repr__(self):
        return "\n".join([" ".join(row) for row in self.to_grid()])


if __name__ == "__main__":
    pos = GomokuPosition(rows=4, cols=4, n_to_win=3)

    while not pos.just_won() and not pos.is_draw():
        print(pos, "\n")

        try:
            if not pos.move(*map(int, input("[row col] :: ").split())):
                print("try again")
        except (ValueError, IndexError):
            print("try again")

    print(pos, "\n")
        
    if pos.just_won():
        print(pos.last_player(), "won")
    else:
        print("draw")

Now the Gomoku GUI module can import the position and use it as a backend for the game logic as shown below. Admittedly, I got a little bored with the GUI so there are plenty of rough edges and questionable UX decisions left as an exercise for the reader.

import itertools
import pygame

from gomoku_position import GomokuPosition

class Colors:
    BLACK = 0, 0, 0
    WHITE = 255, 255, 255
    BROWN = 205, 128, 0


class Gomoku:
    def __init__(
        self,
        size=60,
        piece_size=20,
        rows=15,
        cols=15,
        n_to_win=5,
        caption="Gomoku"
    ):
        self.rows = rows
        self.cols = cols
        self.w = rows * size
        self.h = cols * size
        self.size = size
        self.piece_size = piece_size
        self.half_size = size // 2
        pygame.init()
        pygame.display.set_caption(caption)
        self.screen = pygame.display.set_mode((self.w, self.h))
        self.screen.fill(Colors.WHITE)
        self.player_colors = {"w": Colors.WHITE, "b": Colors.BLACK}
        self.player_names = {"w": "White", "b": "Black"}
        self.board = GomokuPosition(rows, cols, n_to_win)

    def row_lines(self):
        half = self.half_size

        for y in range(half, self.h - half + self.size, self.size):
            yield (half, y), (self.w - half, y)

    def col_lines(self):
        half = self.half_size

        for x in range(half, self.w - half + self.size, self.size):
            yield (x, half), (x, self.h - half)
        
    def draw_background(self):
        rect = pygame.Rect(0, 0, self.w, self.h)
        pygame.draw.rect(self.screen, Colors.BROWN, rect)

    def draw_lines(self):
        lines = itertools.chain(self.col_lines(), self.row_lines())

        for start, end in lines:
            pygame.draw.line(
                self.screen, 
                Colors.BLACK, 
                start, 
                end, 
                width=2
            )

    def draw_board(self):
        self.draw_background()
        self.draw_lines()
        
    def draw_piece(self, row, col):
        player = self.board.last_player()
        circle_pos = (
           col * self.size + self.half_size, 
           row * self.size + self.half_size,
        )
        pygame.draw.circle(
           self.screen, 
           self.player_colors[player], 
           circle_pos, 
           self.piece_size
        )

    def show_outcome(self):
        player = self.player_names[self.board.last_player()]
        msg = "draw!" if self.board.is_draw() else f"{player} wins!"
        font_size = self.w // 10
        font = pygame.font.Font("freesansbold.ttf", font_size)
        label = font.render(msg, True, Colors.WHITE, Colors.BLACK)
        x = self.w // 2 - label.get_width() // 2
        y = self.h // 2 - label.get_height() // 2
        self.screen.blit(label, (x, y))

    def exit_on_click(self):
        while True:
            for event in pygame.event.get():
                if (event.type == pygame.QUIT or 
                        event.type == pygame.MOUSEBUTTONDOWN):
                    pygame.quit()
                    return

    def make_move(self, x, y):
        col = x // self.size
        row = y // self.size
        
        if self.board.move(row, col):
            self.draw_piece(row, col)
        
    def play(self):
        pygame.time.Clock().tick(10)
        self.draw_board()
        pygame.display.update()

        while not self.board.just_won() and not self.board.is_draw():
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    return
                elif event.type == pygame.MOUSEBUTTONDOWN:
                    self.make_move(*event.pos)
                    pygame.display.update()
        
        self.show_outcome()
        pygame.display.update()
        self.exit_on_click()

if __name__ == "__main__":
    game = Gomoku(rows=5, cols=5, n_to_win=4)
    game.play()
\$\endgroup\$
7
\$\begingroup\$

I can suggest a small improvement when you are checking the win with the code:

        if board[r][c] == piece and board[r][c+1] == piece and board[r][c+2] == piece and board[r][c+3] == piece\
            and board[r][c+4] == piece:

You are checking if all of the specified positions are equal to piece, so you could write a helper:

def all_equal(xs, y):
    return all(x == y for x in xs)

And re-use this function for the various checks reducing repetition of this logic concept and making the code cleaner and more readable.


Another way to reduce repetition is in this code block:

            if turn == 0:
                # check if its a valid location then drop a piece
                if is_valid_loc(board, row, col):
                    drop_piece(board, row, col, piece_1)
                    draw_piece(SCREEN,board)

                    if who_wins(board,piece_1):
                        print('Black wins!')
                        SCREEN.blit(label_1, (280,50))
                        pygame.display.update()
                        game_over = True

            # Ask for Player 2 move
            else:
                # check if its a valid location then drop a piece
                if is_valid_loc(board, row, col):
                    drop_piece(board, row, col, piece_2)
                    draw_piece(SCREEN,board)

                    if who_wins(board,piece_2):
                        print('White wins!')
                        SCREEN.blit(label_2, (280,50))
                        pygame.display.update()
                        game_over = True

You could do

if turn == 0:
    piece = piece_1
    name = "Black"
    label = label_1
else:
    piece = piece_2
    name = "White"
    label = label_2

And then:

                if is_valid_loc(board, row, col):
                    drop_piece(board, row, col, piece)
                    draw_piece(SCREEN,board)

                    if who_wins(board,piece):
                        print(name + ' wins!')
                        SCREEN.blit(label, (280,50))
                        pygame.display.update()
                        game_over = True

This makes it clear that players are treated with the same program logic and only some variables change between the handling of the turns of the two players.


The code looks really clean overall, good division of concerns and clear constants at the start.

\$\endgroup\$
6
\$\begingroup\$

There's a lot to improve, but also this is a fun and functional game and you've done well so far.

  • Do not call init from the global namespace
  • This comment:
# create a board array
def create_board(row, col):

and all of your other comments like it are worse than having no comment at all. Even if that comment were informative, you'd want to move it to a standard """docstring""" on the first line inside of the method. As a bonus, some of your comments are not only redundant but just lies, such as # draw game pieces at mouse location. This does not draw at the mouse location; it draws at every single location in the game grid.

  • Currently your Numpy array is float-typed, which is inappropriate. Set an integer type instead.

  • Consider using named argument notation for unclear quantities such as your line width

  • DRY (don't-repeat-yourself) up this code:

          if board[y][x] == 1:
              pygame.draw.circle(screen, BLACK, circle_pos, RADIUS)
          elif board[y][x] == 2:
              pygame.draw.circle(screen, WHITE, circle_pos, RADIUS)
    

so that the call to circle is only written once

  • The recommendation from @Setris is a much better way of doing victory checking. Even if you keep the exhausive method, use some Numpy array slicing to make your life easier.
  • Having a loop termination flag like game_over is usually not a good idea and this is no exception. You can just break or return, once you have a specific-enough method.
  • Not all that useful to print anything. Once your code is working I would delete all of those.
  • SCREEN.fill(WHITE) is not helpful. If your board colour is brown, why not just fill with brown? Resize your window to be the board size (I have not shown this). Furthermore, your board drawing should lose its first two loops altogether - you can draw a single rectangle rather than every tile.
  • SCREEN should be lower-case.
  • Don't track any timing or FPS. You have no animations. Don't event.get; instead event.wait. Also don't hang the program for a few seconds and then exit; let the user exit themselves when they want.
  • Tuple-unpack your event.pos rather than indexing into it.
  • Your turn should use the same value definitions as your pieces. Doing otherwise is needlessly confusing.
  • Why are you calling both is_valid_loc as well as doing # turn decision, if black(1)/white(2) piece already placed, go back to the previous turn? I do not understand this. They seem to do the same thing.
  • Your per-player turn logic is highly repetitive and should only be written once.
  • For draw_piece, rather than iterating over every array element, call np.argwhere
  • There is no sense in pre-arranging for two different labels, only to display one. You can construct and show a label on the fly.
  • Add type hints.

Suggested

I'd go further than this, but this should get you started:

from typing import Tuple

import numpy as np
import math
import pygame
from pygame import display, draw, font, Surface, QUIT, MOUSEBUTTONDOWN

# static variables
ROW_COUNT = 15
COL_COUNT = 15

EMPTY = 0
BLACK_PIECE = 1  # black
WHITE_PIECE = 2  # white
PIECES = (BLACK_PIECE, WHITE_PIECE)

# define screen size
BLOCKSIZE = 50                    # individual grid
S_WIDTH = COL_COUNT * BLOCKSIZE   # screen width
S_HEIGHT = ROW_COUNT * BLOCKSIZE  # screen height
PADDING_RIGHT = 200               # for game menu
SCREENSIZE = (S_WIDTH + PADDING_RIGHT, S_HEIGHT)
RADIUS = 20                       # game piece radius

# colors
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
BROWN = (205, 128, 0)
PIECE_COLOURS = (BLACK, WHITE)


def create_board(row: int, col: int) -> np.ndarray:
    return np.zeros((row, col), dtype=np.int32)


def draw_board(screen: Surface) -> None:
    screen.fill(BROWN)

    # draw vertical inner grid lines
    for x in range(BLOCKSIZE // 2, S_WIDTH + BLOCKSIZE // 2, BLOCKSIZE):
        draw.line(
            screen, BLACK,
            start_pos=(x, BLOCKSIZE // 2),
            end_pos=(x, S_HEIGHT-BLOCKSIZE // 2),
            width=2,
        )

    # draw horizontal inner lines
    for y in range(BLOCKSIZE // 2, S_HEIGHT + BLOCKSIZE // 2, BLOCKSIZE):
        draw.line(
            screen, BLACK,
            start_pos=(BLOCKSIZE // 2, y),
            end_pos=(S_WIDTH - BLOCKSIZE // 2, y),
            width=2,
        )


def drop_piece(board: np.ndarray, row: int, col: int, piece: int) -> None:
    board[row][col] = piece


def pixel_from_grid(x: int, y: int) -> Tuple[int, int]:
    return (
        x * BLOCKSIZE + BLOCKSIZE // 2,
        y * BLOCKSIZE + BLOCKSIZE // 2,
    )


def draw_piece(screen: Surface, board: np.ndarray) -> None:
    for piece, colour in zip(PIECES, PIECE_COLOURS):
        for y, x in np.argwhere(board == piece):
            draw.circle(
                screen, PIECE_COLOURS[board[y][x] - 1],
                pixel_from_grid(x, y), RADIUS,
            )
    display.update()


def is_valid_loc(board: np.ndarray, row: int, col: int) -> bool:
    return board[row][col] == EMPTY


def who_wins(board: np.ndarray, piece: int) -> bool:
    # check for horizontal win
    for c in range(COL_COUNT - 4):
        for r in range(ROW_COUNT):
            if np.all(board[r, c:c+5] == piece):
                return True

    # check for vertical win
    for c in range(COL_COUNT):
        for r in range(ROW_COUNT - 4):
            if np.all(board[r:r+5, c] == piece):
                return True

    # check for positively sloped diagonal wih
    for c in range(COL_COUNT - 4):
        for r in range(4, ROW_COUNT):
            if (
                board[r, c] == piece
                and board[r-1, c+1] == piece
                and board[r-2, c+2] == piece
                and board[r-3, c+3] == piece
                and board[r-4, c+4] == piece
            ):
                return True

    # check for negatively sloped diagonal win
    for c in range(COL_COUNT - 4):
        for r in range(ROW_COUNT - 4):
            if (
                board[r, c] == piece
                and board[r+1, c+1] == piece
                and board[r+2, c+2] == piece
                and board[r+3, c+3] == piece
                and board[r+4, c+4] == piece
            ):
                return True

    return False


def setup_gui() -> Surface:
    screen = display.set_mode(SCREENSIZE)
    display.set_caption('Gomoku (Connet 5)')
    # icon = pygame.image.load('icon.png')
    # pygame.display.set_icon(icon)

    draw_board(screen)
    display.update()
    return screen


def end_banner(screen: Surface, piece: str) -> None:
    my_font = font.Font('freesansbold.ttf', 32)
    label = my_font.render(f'{piece} wins!', True, WHITE, BLACK)
    screen.blit(label, (280, 50))
    display.update()


def game(screen: Surface) -> None:
    turn = BLACK_PIECE

    # board 2D array
    board = create_board(ROW_COUNT, COL_COUNT)

    # game loop
    while True:
        event = pygame.event.wait()
        if event.type == QUIT:
            return

        elif event.type == MOUSEBUTTONDOWN:
            x_pos, y_pos = event.pos
            col = math.floor(x_pos / BLOCKSIZE)
            row = math.floor(y_pos / BLOCKSIZE)

            if not is_valid_loc(board, row, col):
                continue

            drop_piece(board, row, col, turn)
            draw_piece(screen, board)

            if who_wins(board, turn):
                name = 'Black' if turn == BLACK_PIECE else 'White'
                end_banner(screen, name)
                return

            turn = 3 - turn


def main() -> None:
    # initialize the pygame program
    pygame.init()
    try:
        screen = setup_gui()
        game(screen)
        while pygame.event.wait().type != QUIT:
            pass
    finally:
        pygame.quit()


if __name__ == '__main__':
    main()
\$\endgroup\$
5
\$\begingroup\$

To check if a player won, you don't need to scan the entire board every time. Scanning only around the piece that was just placed in all directions (horizontal, vertical, positively sloped diagonal, and negatively sloped diagonal) is enough.

Here's an example implementation of this idea:

from enum import Enum, auto


class Direction(Enum):
    N = auto()
    NE = auto()
    E = auto()
    SE = auto()
    S = auto()
    SW = auto()
    W = auto()
    NW = auto()

    @property
    def vector(self):
        return {
            Direction.N: (-1, 0),
            Direction.NE: (-1, 1),
            Direction.E: (0, 1),
            Direction.SE: (1, 1),
            Direction.S: (1, 0),
            Direction.SW: (1, -1),
            Direction.W: (0, -1),
            Direction.NW: (-1, -1),
        }[self]


def count(board, row, col, direction):
    """
    Return the number of consecutive pieces matching the starting piece's
    color, starting from (but not including) the starting piece, and going
    in the given direction.

    row: starting piece's row
    col: starting piece's column
    """

    def on_board(row, col):
        return 0 <= row < ROW_COUNT and 0 <= col < COL_COUNT

    piece_color = board[row][col]
    row_delta, col_delta = direction.vector

    count = 0
    row, col = row + row_delta, col + col_delta
    while on_board(row, col) and board[row][col] == piece_color:
        count += 1
        row, col = row + row_delta, col + col_delta

    return count


def is_win(board, row, col):
    """
    Returns True if the piece played on (row, col) wins the game.
    """

    def is_win_helper(board, row, col, d1, d2):
        return count(board, row, col, d1) + 1 + count(board, row, col, d2) >= 5

    return (
        # horizontal win
        is_win_helper(board, row, col, Direction.W, Direction.E)
        # vertical win
        or is_win_helper(board, row, col, Direction.N, Direction.S)
        # positively sloped diagonal win
        or is_win_helper(board, row, col, Direction.SW, Direction.NE)
        # negatively sloped diagonal win
        or is_win_helper(board, row, col, Direction.NW, Direction.SE)
    )

Then you can check if the player who just played at (row, col) won by doing:

if is_win(board, row, col):
    # ...
\$\endgroup\$

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.