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!

asked Jul 10, 2021 at 18:53
\$\endgroup\$
0

4 Answers 4

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()
answered Jul 12, 2021 at 3:52
\$\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.

answered Jul 11, 2021 at 13:07
\$\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()
answered Jul 12, 2021 at 3:43
\$\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):
 # ...
answered Jul 11, 2021 at 23:45
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.