Build a 2-player maze game with Python Part 6

by Dan Duda

Requirements

  • Python 3.7
  • PyCharm Community (optional)
  • Basic Python knowledge

Goto Part 5

Introduction

In part 5 we finished the game, making it playable by two players on one keyboard with score keeping. However we’re really not done. It’s always good to have another person test your app. A few things came up when I had another family member try the game on their computer.

  1. It became more apparent that one player has it much easier when generating the maze from the corner.
  2. It did not run as fast on another computer. In fact it was quite sluggish.
  3. The other user had a lot of frustration with the controls trying to move into a pathway that is in the middle of a corridor.

Let’s tackle these in order.

Balance

We tried to solve the issue of balance in the previous parts by alternating which corner we start generating the maze from. Recall that there is not a lot of back-tracking at the beginning so the player starting in the corner where the maze generated from will have less choices to make early on. Do we havbe to start from a corner? Of course not. The algorithm will work just as well starting from any cell in the maze grid. So why not start at the center.

So before we had code that looked like this:

1
2
3
4
5
6
7
8
# Start at alternating corners to be more fair
process_stack = [(0, 0)]
if self.round % 2 == 0:
process_stack = [(MAZE_WIDTH - 1, MAZE_HEIGHT - 1)]
self.maze[CELL_COUNT - 1] |= CellProp.CELL_VISITED.value
else:
process_stack = [(0, 0)]
self.maze[0] |= CellProp.CELL_VISITED.value

Now we’ll get rid of the concept of a round since we won’t need to alternate and just always start from the center.

1
2
3
# Start in middle of maze
process_stack = [(18, 15)]
self.maze[self.get_cell_index((18,15))] |= CellProp.Visited.value

And that’s it. Now it should be in general just as challenging whether playing as red or blue.

Display optimization

We were able to optimize the display and get our FPS up by saving the image of the maze after generating and just drawing it to the screen each frame instead of processing the whole maze each time. That definitely sped our game up but when testing on another PC I found there were still issues.

Each time through our game loop we are re-drawing the whole screen even though only a small portion of the screen is ever changing at one time. PyGame offers solutions for this. The “display.update” method takes an optional paramter which is a list of rectangles. These rectangles are the areas that have changed since the last update. We’ll call them “dirty rectangles”.

Because of this I refactored the “draw_screen” into several separate draw methods for the different parts of our display and implemented the “dirty recs” functionality.

I removed the call to “draw_screen” in our game loop and created the following methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def draw_scores(self):
# Display Scores
font = pg.font.SysFont('Arial', 18, True)

p1_msg = f'PLAYER 1: {self.player1_score}'
p2_msg = f'PLAYER 2: {self.player2_score}'
p1_size = font.size(p1_msg)
p2_size = font.size(p2_msg)
p1 = font.render(p1_msg, True, PLAYER1_COLOR)
p2 = font.render(p2_msg, True, PLAYER2_COLOR)

p1_x = MAZE_TOP_LEFT_CORNER[0]
p1_y = MAZE_TOP_LEFT_CORNER[1] - p1_size[1]
p1_w = p1.get_rect().w
p1_h = p1.get_rect().h
p2_x = MAZE_TOP_LEFT_CORNER[0] + MAZE_WIDTH_PX - p2_size[0]
p2_y = MAZE_TOP_LEFT_CORNER[1] - p1_size[1]
p2_w = p2.get_rect().w
p2_h = p2.get_rect().h

self.screen.blit(p1, (p1_x, p1_y))
self.screen.blit(p2, (p2_x, p2_y))

pg.display.update([(p1_x, p1_y, p1_w, p1_h), (p2_x, p2_y, p2_w, p2_h)])

def draw_instructions(self):
# Display instructions
font = pg.font.SysFont('Arial', 18, True)

p1_msg = 'a w s d to move'
p2_msg = '← ↑ ↓ → to move'
p2_size = font.size(p2_msg)
p1 = font.render(p1_msg, True, PLAYER1_COLOR)
p2 = font.render(p2_msg, True, PLAYER2_COLOR)

p1_x = MAZE_TOP_LEFT_CORNER[0]
p1_y = MAZE_TOP_LEFT_CORNER[1] + MAZE_HEIGHT_PX + 2
p1_w = p1.get_rect().w
p1_h = p1.get_rect().h
p2_x = MAZE_TOP_LEFT_CORNER[0] + MAZE_WIDTH_PX - p2_size[0]
p2_y = MAZE_TOP_LEFT_CORNER[1] + MAZE_HEIGHT_PX + 2
p2_w = p2.get_rect().w
p2_h = p2.get_rect().h

self.screen.blit(p1, (p1_x, p1_y))
self.screen.blit(p2, (p2_x, p2_y))

pg.display.update([(p1_x, p1_y, p1_w, p1_h), (p2_x, p2_y, p2_w, p2_h)])

def draw_players(self):
self.all_sprites.clear(self.screen, self.maze_image)
dirty_recs = self.all_sprites.draw(self.screen)
pg.display.update(dirty_recs)

def draw_win(self):
msg = 'Player 1 Wins!!!' if self.win1_flag else 'Player 2 Wins!!!'

if self.win1_flag:
self.player1_score += 1
else:
self.player2_score += 1

self.draw_scores()

font = pg.font.SysFont('Arial', 72, True)
size = font.size(msg)
s = font.render(msg, True, MESSAGE_COLOR, (0, 0, 0))

x = SCREEN_WIDTH // 2 - size[0] // 2
y = SCREEN_HEIGHT // 2 - size[1] // 2
w = s.get_rect().w
h = s.get_rect().h

self.screen.blit(s, (x, y))
pg.display.update([(x, y, w, h)])

pg.time.wait(3000)

We now blit the area that needs ghanging when we call each method and then call update with the list of rectangle areas to update. We also made a change to the sprite mechanism we were using. In the “init” method I removed the player sprite variables and instead use:

1
2
3
self.all_sprites = pg.sprite.RenderUpdates()
self.all_sprites.add(self.player1)
self.all_sprites.add(self.player2)

The “RenderUpdates” sprite group gives us a lot of automatic functionality as far as updating the sprites as they move. In the “draw_players” above you can see we just need to clear the sprites, call the draw method on the sprite group which returns the list of “dirty recs” and then we update the display passing in those “dirty recs”. There were other minor changes as well in our “run_game” method, etc. See the full code listing at the end for all the changes.

Running with these changes put my FPS up over 2000! This actually made it hard to play since when moving the player the sprite would zip around way too fast. To solve this I added a line at the end of our game loop to fix the FPS to 200 FPS which felt about right.

1
clock.tick(200)

Control Assist

Lastly the other user really had a bad experience with our game because of the controls. When trying to navigate to a pathway that opens in the middle of a corridor instead of at an end it can be difficult. This is because our “can_move” check is very restrictive. The sprite has to be pixel perfect centered on the pathway to be able to move into the corridor. The way I learned to handle this was to, for example, if in a north/south corridor and trying to move down a corridor to the right, I will hold down the move right key while also pressing the up or down keys. This works and and is fine after some practice but can be a put off for a lot of users.

To “assist” the user when navigating we can think of it this way.

  1. The user tries to move in a certain direction
  2. If the player can move in that direction we move the sprite (this is what we have so far)
  3. If they can’t move in that direction, check if the player is near an opening in the direction they’re trying to go
  4. If they are near an opening then move them along the current corridor towards that opening

So in other words say I’m moving south along a corridor and there’s an opening to the east below my sprite. If I press the key to move right nothing will currently happen. So now when I press the move right key I will continue moving south until lined up with the pathway and then move right into the pathway.

There was a bit of refactoring with the move code as well. I will focus on the main changes and can see the full code at the end of this tutorial.

In the main game loop where we test for the key presses we now call a new method called “try_move”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def try_move(self, player, direction):
if self.can_move(direction, player):
self.move(player, direction.value)
else:
# Check if open corridor is nearby
index1, index2 = self.get_player_cell_indexes(player)

move1 = self.maze[index1] & self.direction_to_flag[direction].value
move2 = self.maze[index2] & self.direction_to_flag[direction].value

if move1 or move2:
# Move assist - move player closer to closest pathway in direction player is trying to move
# We know that index1 and index2 must be different cells
# get direction of closest pathway
# measure center of player to center of each cell
player_center = player.rect.x + (player.rect.w // 2), player.rect.y + (player.rect.h // 2)

corner_offset_x = MAZE_TOP_LEFT_CORNER[0] + BLOCK_SIZE
corner_offset_y = MAZE_TOP_LEFT_CORNER[1] + BLOCK_SIZE
cell1_x, cell1_y = index1 % MAZE_WIDTH, index1 // MAZE_WIDTH
cell2_x, cell2_y = index2 % MAZE_WIDTH, index2 // MAZE_WIDTH

square = BLOCK_SIZE * 4
cell1_x_px = corner_offset_x + cell1_x * square
cell1_y_px = corner_offset_y + cell1_y * square
cell2_x_px = corner_offset_x + cell2_x * square
cell2_y_px = corner_offset_y + cell2_y * square

cell1_center = cell1_x_px + (BLOCK_SIZE * PATH_WIDTH) // 2, cell1_y_px + (BLOCK_SIZE * PATH_WIDTH) // 2
cell2_center = cell2_x_px + (BLOCK_SIZE * PATH_WIDTH) // 2, cell2_y_px + (BLOCK_SIZE * PATH_WIDTH) // 2

if cell1_center[0] == player_center[0]:
# player is N/S corridor
if move1 and move2:
l1, l2 = abs(player_center[1] - cell1_center[1]), abs(player_center[1] - cell2_center[1])
if l1 < l2:
# move up
self.move(player, Direction.North.value)
else:
# move down
self.move(player, Direction.South.value)
else:
if move1:
# move up
self.move(player, Direction.North.value)
else:
# move down
self.move(player, Direction.South.value)
else:
# player is E/W corridor
if move1 and move2:
l1, l2 = abs(player_center[0] - cell1_center[0]), abs(player_center[0] - cell2_center[0])
if l1 < l2:
# move left
self.move(player, Direction.West.value)
else:
# move right
self.move(player, Direction.East.value)
else:
if move1:
# move left
self.move(player, Direction.West.value)
else:
# move right
self.move(player, Direction.East.value)

Lines 2 and 3 are what we had before. It checks if the player can move in the direction they are requesting and if so it moves the sprite.

Lines 6-9 get the indexes of the maze cells that the player currently occupies. The variables move1 and move2 are booleans stating whether the cells have pathways in the direction the player is trying to go.

The rest of the code gets the x and y coordinates in pixels of the player sprite and the two maze cells and calculates the center point of them. It then determines if the player is in a north/south or east/west corridor by seeing if the center x or y coordinate matches. It then determines if both cells have pathways, and if so determines the closest one to the player sprite. Finally it moves the player towards the pathway. Try out both versions of the code and see how the player moves much more smoothly into pathways now.

Full source code and code download

Download code: maze.zip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
# Simple Maze Game Using The Recursive Back-Tracker Algorithm
from enum import Enum
import random
import pygame as pg


# Global Settings
SHOW_DRAW = False # Show the maze being created
SHOW_FPS = False # Show frames per second in caption
SCREEN_WIDTH = 1200
SCREEN_HEIGHT = 960
# Maze Size: 36 X 30 is max size for screen of 1200 X 1024
MAZE_WIDTH = 36 # in cells
MAZE_HEIGHT = 28 # in cells
CELL_COUNT = MAZE_WIDTH * MAZE_HEIGHT
BLOCK_SIZE = 8 # Pixel size/Wall thickness
PATH_WIDTH = 3 # Width of pathway in blocks
CELL_SIZE = BLOCK_SIZE * PATH_WIDTH + BLOCK_SIZE # extra BLOCK_SIZE to include wall to east and south of cell
MAZE_WIDTH_PX = CELL_SIZE * MAZE_WIDTH + BLOCK_SIZE # extra BLOCK_SIZE to include left edge wall
MAZE_HEIGHT_PX = CELL_SIZE * MAZE_HEIGHT + BLOCK_SIZE # extra BLOCK_SIZE to include top edge wall
MAZE_TOP_LEFT_CORNER = (SCREEN_WIDTH // 2 - MAZE_WIDTH_PX // 2, SCREEN_HEIGHT // 2 - MAZE_HEIGHT_PX // 2)

# Define the colors we'll need
BACK_COLOR = (100, 100, 100)
WALL_COLOR = (18, 94, 32)
MAZE_COLOR = (255, 255, 255)
UNVISITED_COLOR = (0, 0, 0)
PLAYER1_COLOR = (255, 0, 0)
PLAYER2_COLOR = (0, 0, 255)
MESSAGE_COLOR = (0, 255, 0)


class CellProp(Enum):
Path_N = 1
Path_E = 2
Path_S = 4
Path_W = 8
Visited = 16


class Direction(Enum):
North = (0, -1)
East = (1, 0)
South = (0, 1)
West = (-1, 0)


class Player(pg.sprite.Sprite):
def __init__(self, color, x, y, radius):
# Call the parent class (Sprite) constructor
super().__init__()

# Save the start position
self.start_x = x
self.start_y = y

# Create the rectangular image, fill and set background to transparent
self.image = pg.Surface([radius * 2, radius * 2])
self.image.fill(MAZE_COLOR)
self.image.set_colorkey(MAZE_COLOR)

# Draw our player onto the transparent rectangle
pg.draw.circle(self.image, color, (radius, radius), radius)

self.rect = self.image.get_rect()
self.rect.x = x
self.rect.y = y

def reset(self):
self.rect.x = self.start_x
self.rect.y = self.start_y


class MazeGenerator:
direction_to_flag = {
Direction.North: CellProp.Path_N,
Direction.East: CellProp.Path_E,
Direction.South: CellProp.Path_S,
Direction.West: CellProp.Path_W
}

opposite_direction = {
Direction.North: Direction.South,
Direction.East: Direction.West,
Direction.South: Direction.North,
Direction.West: Direction.East
}

def __init__(self):
# Need to initialize pygame before using it
pg.init()

# Create a display surface to draw our game on
self.screen = pg.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))

# Set the title on the window
pg.display.set_caption('PyMaze')

# Use a single list to store 2D array
self.maze = []

random.seed()

# Store maze as image after we create it so that we just have to redraw the image on update
self.maze_image = None

# Create players
self.player1 = Player(PLAYER1_COLOR, MAZE_TOP_LEFT_CORNER[0] + BLOCK_SIZE,
MAZE_TOP_LEFT_CORNER[1] + BLOCK_SIZE, (BLOCK_SIZE * 3) // 2)

self.player2 = Player(PLAYER2_COLOR,
MAZE_TOP_LEFT_CORNER[0] + MAZE_WIDTH_PX - CELL_SIZE,
MAZE_TOP_LEFT_CORNER[1] + MAZE_HEIGHT_PX - CELL_SIZE,
(BLOCK_SIZE * 3) // 2)

self.all_sprites = pg.sprite.RenderUpdates()
self.all_sprites.add(self.player1)
self.all_sprites.add(self.player2)

self.player1_score = 0
self.player2_score = 0

self.win1_flag = False
self.win2_flag = False

@staticmethod
def get_cell_index(position):
x, y = position
return y * MAZE_WIDTH + x

def generate_maze(self):
# Initialize maze with zero values
self.maze = [0] * CELL_COUNT
visited_count = 0

# Start in middle of maze
process_stack = [(18, 14)]
self.maze[self.get_cell_index((18,14))] |= CellProp.Visited.value

visited_count += 1
while visited_count < CELL_COUNT:
# Step 1 - Create a list of the unvisited neighbors
x, y = process_stack[-1] # get position of top item on stack
current_cell_index = self.get_cell_index((x, y))

# Find all unvisited neighbors
neighbors = []
for direction in Direction:
dir = direction.value
new_x, new_y = (x + dir[0], y + dir[1])
if 0 <= new_x < MAZE_WIDTH and 0 <= new_y < MAZE_HEIGHT:
index = self.get_cell_index((new_x, new_y))
if not self.maze[index] & CellProp.Visited.value:
# Cell was not already visited so add to neighbors list with the direction
neighbors.append((new_x, new_y, direction))

if len(neighbors) > 0:
# Choose a random neighboring cell
cell = neighbors[random.randrange(len(neighbors))]
cell_x, cell_y, cell_direction = cell
cell_position = (cell_x, cell_y)
cell_index = self.get_cell_index(cell_position)

# Create a path between the neighbor and the current cell by setting the direction property flag
flag_to = MazeGenerator.direction_to_flag[cell_direction]
flag_from = MazeGenerator.direction_to_flag[MazeGenerator.opposite_direction[cell_direction]]

self.maze[current_cell_index] |= flag_to.value
self.maze[cell_index] |= flag_from.value | CellProp.Visited.value

process_stack.append(cell_position)
visited_count += 1
else:
# Backtrack since there were no unvisited neighbors
process_stack.pop()

if SHOW_DRAW:
self.draw_maze()
pg.display.update()
#pg.time.wait(500)
pg.event.pump()

# save image of completed maze
self.draw_maze()
pg.display.update()
self.maze_image = self.screen.copy()

def draw_maze(self):
self.screen.fill(BACK_COLOR)
pg.draw.rect(self.screen, WALL_COLOR, (MAZE_TOP_LEFT_CORNER[0], MAZE_TOP_LEFT_CORNER[1],
MAZE_WIDTH_PX, MAZE_HEIGHT_PX))

for x in range(MAZE_WIDTH):
for y in range(MAZE_HEIGHT):
for py in range(PATH_WIDTH):
for px in range(PATH_WIDTH):
cell_index = self.get_cell_index((x, y))
if self.maze[cell_index] & CellProp.Visited.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + px, y * (PATH_WIDTH + 1) + py)
else:
self.draw(UNVISITED_COLOR, x * (PATH_WIDTH + 1) + px, y * (PATH_WIDTH + 1) + py)

for p in range(PATH_WIDTH):
if self.maze[y * MAZE_WIDTH + x] & CellProp.Path_S.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + p, y * (PATH_WIDTH + 1) + PATH_WIDTH)

if self.maze[y * MAZE_WIDTH + x] & CellProp.Path_E.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + PATH_WIDTH, y * (PATH_WIDTH + 1) + p)

# Color the player exits
pg.draw.rect(self.screen, PLAYER2_COLOR, (MAZE_TOP_LEFT_CORNER[0],
MAZE_TOP_LEFT_CORNER[1] + BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE * 3))

pg.draw.rect(self.screen, PLAYER1_COLOR,
(MAZE_TOP_LEFT_CORNER[0] + MAZE_WIDTH_PX - BLOCK_SIZE,
MAZE_TOP_LEFT_CORNER[1] + MAZE_HEIGHT_PX - BLOCK_SIZE * 4,
BLOCK_SIZE, BLOCK_SIZE * 3))

def draw(self, color, x, y):
x_offset = MAZE_TOP_LEFT_CORNER[0] + BLOCK_SIZE
y_offset = MAZE_TOP_LEFT_CORNER[1] + BLOCK_SIZE
pg.draw.rect(self.screen, color, (x * BLOCK_SIZE + x_offset,
y * BLOCK_SIZE + y_offset,
BLOCK_SIZE, BLOCK_SIZE))

def draw_scores(self):
# Display Scores
font = pg.font.SysFont('Arial', 18, True)

p1_msg = f'PLAYER 1: {self.player1_score}'
p2_msg = f'PLAYER 2: {self.player2_score}'
p1_size = font.size(p1_msg)
p2_size = font.size(p2_msg)
p1 = font.render(p1_msg, True, PLAYER1_COLOR)
p2 = font.render(p2_msg, True, PLAYER2_COLOR)

p1_x = MAZE_TOP_LEFT_CORNER[0]
p1_y = MAZE_TOP_LEFT_CORNER[1] - p1_size[1]
p1_w = p1.get_rect().w
p1_h = p1.get_rect().h
p2_x = MAZE_TOP_LEFT_CORNER[0] + MAZE_WIDTH_PX - p2_size[0]
p2_y = MAZE_TOP_LEFT_CORNER[1] - p1_size[1]
p2_w = p2.get_rect().w
p2_h = p2.get_rect().h

self.screen.blit(p1, (p1_x, p1_y))
self.screen.blit(p2, (p2_x, p2_y))

pg.display.update([(p1_x, p1_y, p1_w, p1_h), (p2_x, p2_y, p2_w, p2_h)])

def draw_instructions(self):
# Display instructions
font = pg.font.SysFont('Arial', 18, True)

p1_msg = 'a w s d to move'
p2_msg = '← ↑ ↓ → to move'
p2_size = font.size(p2_msg)
p1 = font.render(p1_msg, True, PLAYER1_COLOR)
p2 = font.render(p2_msg, True, PLAYER2_COLOR)

p1_x = MAZE_TOP_LEFT_CORNER[0]
p1_y = MAZE_TOP_LEFT_CORNER[1] + MAZE_HEIGHT_PX + 2
p1_w = p1.get_rect().w
p1_h = p1.get_rect().h
p2_x = MAZE_TOP_LEFT_CORNER[0] + MAZE_WIDTH_PX - p2_size[0]
p2_y = MAZE_TOP_LEFT_CORNER[1] + MAZE_HEIGHT_PX + 2
p2_w = p2.get_rect().w
p2_h = p2.get_rect().h

self.screen.blit(p1, (p1_x, p1_y))
self.screen.blit(p2, (p2_x, p2_y))

pg.display.update([(p1_x, p1_y, p1_w, p1_h), (p2_x, p2_y, p2_w, p2_h)])

def draw_players(self):
self.all_sprites.clear(self.screen, self.maze_image)
dirty_recs = self.all_sprites.draw(self.screen)
pg.display.update(dirty_recs)

def draw_win(self):
msg = 'Player 1 Wins!!!' if self.win1_flag else 'Player 2 Wins!!!'

if self.win1_flag:
self.player1_score += 1
else:
self.player2_score += 1

self.draw_scores()

font = pg.font.SysFont('Arial', 72, True)
size = font.size(msg)
s = font.render(msg, True, MESSAGE_COLOR, (0, 0, 0))

x = SCREEN_WIDTH // 2 - size[0] // 2
y = SCREEN_HEIGHT // 2 - size[1] // 2
w = s.get_rect().w
h = s.get_rect().h

self.screen.blit(s, (x, y))
pg.display.update([(x, y, w, h)])

pg.time.wait(3000)

def get_player_cell_indexes(self, player):
# Top left corner of first cell
corner_offset_x = MAZE_TOP_LEFT_CORNER[0] + BLOCK_SIZE
corner_offset_y = MAZE_TOP_LEFT_CORNER[1] + BLOCK_SIZE

# Calculate which cells the player occupies
square = BLOCK_SIZE * 4
p1 = (player.rect.x - corner_offset_x, player.rect.y - corner_offset_y)
p2 = (p1[0] + square - 1, p1[1] + square - 1)
player_pos1 = (p1[0] // square, p1[1] // square)
player_pos2 = (p2[0] // square, p2[1] // square)
cell_index1 = self.get_cell_index((player_pos1[0], player_pos1[1]))
cell_index2 = self.get_cell_index((player_pos2[0], player_pos2[1]))

return cell_index1, cell_index2

def can_move(self, direction, player):
cell_index1, cell_index2 = self.get_player_cell_indexes(player)

functions = {
Direction.North: self.can_move_up,
Direction.East: self.can_move_right,
Direction.South: self.can_move_down,
Direction.West: self.can_move_left
}

# Check for maze exit/win
# Check if player is at opposing player's start x,y
if self.player1.rect.x == self.player2.start_x and self.player1.rect.y == self.player2.start_y:
self.win1_flag = True
elif self.player2.rect.x == self.player1.start_x and self.player2.rect.y == self.player1.start_y:
self.win2_flag = True

return functions[direction](cell_index1, cell_index2)

def can_move_up(self, index1, index2):
if index1 == index2:
return self.maze[index1] & CellProp.Path_N.value
else:
return index2 == index1 + MAZE_WIDTH

def can_move_right(self, index1, index2):
if index1 == index2:
return self.maze[index1] & CellProp.Path_E.value
else:
return index2 == index1 + 1

def can_move_down(self, index1, index2):
if index1 == index2:
return self.maze[index1] & CellProp.Path_S.value
else:
return index2 == index1 + MAZE_WIDTH

def can_move_left(self, index1, index2):
if index1 == index2:
return self.maze[index1] & CellProp.Path_W.value
else:
return index2 == index1 + 1

def move(self, player, move):
x, y = move
player.rect.x += x
player.rect.y += y
self.draw_players()

def try_move(self, player, direction):
if self.can_move(direction, player):
self.move(player, direction.value)
else:
# Check if open corridor is nearby
index1, index2 = self.get_player_cell_indexes(player)

move1 = self.maze[index1] & self.direction_to_flag[direction].value
move2 = self.maze[index2] & self.direction_to_flag[direction].value

if move1 or move2:
# Move assist - move player closer to closest pathway in direction player is trying to move
# We know that index1 and index2 must be different cells
# get direction of closest pathway
# measure center of player to center of each cell
player_center = player.rect.x + (player.rect.w // 2), player.rect.y + (player.rect.h // 2)

corner_offset_x = MAZE_TOP_LEFT_CORNER[0] + BLOCK_SIZE
corner_offset_y = MAZE_TOP_LEFT_CORNER[1] + BLOCK_SIZE
cell1_x, cell1_y = index1 % MAZE_WIDTH, index1 // MAZE_WIDTH
cell2_x, cell2_y = index2 % MAZE_WIDTH, index2 // MAZE_WIDTH

square = BLOCK_SIZE * 4
cell1_x_px = corner_offset_x + cell1_x * square
cell1_y_px = corner_offset_y + cell1_y * square
cell2_x_px = corner_offset_x + cell2_x * square
cell2_y_px = corner_offset_y + cell2_y * square

cell1_center = cell1_x_px + (BLOCK_SIZE * PATH_WIDTH) // 2, cell1_y_px + (BLOCK_SIZE * PATH_WIDTH) // 2
cell2_center = cell2_x_px + (BLOCK_SIZE * PATH_WIDTH) // 2, cell2_y_px + (BLOCK_SIZE * PATH_WIDTH) // 2

if cell1_center[0] == player_center[0]:
# player is N/S corridor
if move1 and move2:
l1, l2 = abs(player_center[1] - cell1_center[1]), abs(player_center[1] - cell2_center[1])
if l1 < l2:
# move up
self.move(player, Direction.North.value)
else:
# move down
self.move(player, Direction.South.value)
else:
if move1:
# move up
self.move(player, Direction.North.value)
else:
# move down
self.move(player, Direction.South.value)
else:
# player is E/W corridor
if move1 and move2:
l1, l2 = abs(player_center[0] - cell1_center[0]), abs(player_center[0] - cell2_center[0])
if l1 < l2:
# move left
self.move(player, Direction.West.value)
else:
# move right
self.move(player, Direction.East.value)
else:
if move1:
# move left
self.move(player, Direction.West.value)
else:
# move right
self.move(player, Direction.East.value)

def initialize(self):
self.player1.reset()
self.player2.reset()
self.generate_maze()
self.draw_instructions()
self.draw_scores()
self.draw_players()

def run_game(self):
clock = pg.time.Clock()
self.initialize()

# Main game loop
run = True
while run:
for event in pg.event.get():
if event.type == pg.QUIT:
run = False

if not self.win1_flag and not self.win2_flag:
keys = pg.key.get_pressed()
if keys[pg.K_LEFT]:
self.try_move(self.player2, Direction.West)
if keys[pg.K_RIGHT]:
self.try_move(self.player2, Direction.East)
if keys[pg.K_UP]:
self.try_move(self.player2, Direction.North)
if keys[pg.K_DOWN]:
self.try_move(self.player2, Direction.South)
if keys[pg.K_a]:
self.try_move(self.player1, Direction.West)
if keys[pg.K_d]:
self.try_move(self.player1, Direction.East)
if keys[pg.K_w]:
self.try_move(self.player1, Direction.North)
if keys[pg.K_s]:
self.try_move(self.player1, Direction.South)

if self.win1_flag or self.win2_flag:
self.draw_win()
self.win1_flag = self.win2_flag = False
self.initialize()

if SHOW_FPS:
pg.display.set_caption(f'PyMaze ({str(int(clock.get_fps()))} FPS)')
clock.tick()
else:
clock.tick(200)

pg.quit()


mg = MazeGenerator()
mg.run_game()