9
\$\begingroup\$

I posted a question a while back asking for some feedback on the code of a game I made (it was limited to typing the input and drawing the output in ASCII).

Now I've got it linked up with pygamef. Does anything look out of place? Do you notice any bugs? Do the colours work? Is there anything particularly annoying?

Use CTRL+SHIFT+d while in options (hit ESC to bring them up if you've already started the game) to reveal the debug settings, and enable to see the mouse coordinate conversion and AI stuff going on under the hood.

Instructions

The aim is to get as many complete rows as you can, and the grid will flip every 3 turns to throw you off, otherwise it gets a bit easy. The game ends when all spaces are taken (though this is a bit annoying when you are having to fill in the last few ones, so I'll just make it end when there are no points left).

At this time, I still need to make the instructions page and a 'player x won' page, though everything else is working without bugs as far as I can tell.

Normal game:

enter image description here

With debug enabled:

enter image description here

To see the entire thing, you'll need this link. If you don't have pygame (or python for that matter), here is a standalone version of the game from py2exe.

class MouseToBlockID(object):
 """Converts mouse coordinates into the games block ID.
 The first part is to calculate which level has been clicked, which
 then allows the code to treat the coordinates as level 0. From this
 point, it finds the matching chunks from the new coordinates which
 results in two possible blocks, then it calculates how they are
 conected (highest one is to the left if even+odd, otherwise it's to
 the right), and from that, it's possible to figure out which block
 the cursor is over.
 A chunk is a cell of a 2D grid overlaid over the isometric grid.
 Each block is split into 4 chunks, and each chunk overlaps two
 blocks.
 """
 def __init__(self, x, y, grid_main):
 self.x = x
 self.y = y
 self.y_original = y
 self.grid_main = grid_main
 self._to_chunk()
 def _to_chunk(self):
 """Calculate which chunk the coordinate is on."""
 y_offset = self.grid_main.size_y * 2 + self.grid_main.padding
 self.y_coordinate = int((self.grid_main.centre - self.y) / y_offset)
 self.y += y_offset * self.y_coordinate
 chunk_size_x = self.grid_main.size_x / self.grid_main.segments
 chunk_size_y = self.grid_main.size_y / self.grid_main.segments
 self.height = int((self.grid_main.centre - self.y) / chunk_size_y)
 self.width = int((self.x + self.grid_main.size_x + chunk_size_x) / chunk_size_x) -1
 def find_x_slice(self):
 """Find block IDs that are on the x segment"""
 past_middle = self.width >= self.grid_main.segments
 values = []
 if self.width >= self.grid_main.segments:
 count = 0
 while True:
 n_multiple = self.grid_main.segments * count
 width_addition = self.width - self.grid_main.segments + count
 if width_addition < self.grid_main.segments:
 values.append(n_multiple + width_addition)
 if width_addition < self.grid_main.segments - 1:
 values.append(n_multiple + width_addition + 1)
 else:
 break
 count += 1
 elif self.width >= 0:
 starting_point = self.grid_main.segments - self.width
 values.append((starting_point - 1) * self.grid_main.segments)
 width_addition = 0
 for i in range(starting_point, self.grid_main.segments):
 n_multiple = self.grid_main.segments * i
 values.append(n_multiple + width_addition)
 if 0 < i < self.grid_main.segments:
 values.append(n_multiple + width_addition + 1)
 else:
 break
 width_addition += 1
 return values
 def find_y_slice(self):
 """Find block IDs that are on the y segment"""
 height = self.height
 past_middle = height >= self.grid_main.segments
 if past_middle:
 height = 2 * self.grid_main.segments - 1 - height
 values = []
 count = 0
 while True:
 n_multiple = count * self.grid_main.segments
 height_addition = height - count
 if height_addition >= 0:
 values.append(n_multiple + height_addition)
 if height_addition >= 1:
 values.append(n_multiple + height_addition - 1)
 else:
 break
 count += 1
 if past_middle:
 values = [pow(self.grid_main.segments, 2) - i - 1 for i in values]
 return values
 def find_overlap(self):
 """Combine the block IDs to find the 1 or 2 matching ones."""
 x_blocks = self.find_x_slice()
 y_blocks = self.find_y_slice()
 if self.y_coordinate >= self.grid_main.segments:
 return []
 return [i for i in x_blocks if i in y_blocks]
 def find_block_coordinates(self):
 """Calculate the coordinates of the block IDs, or create a fake
 block if one is off the edge.
 Returns a list sorted by height.
 If only one value is given for which blocks are in the chunk, that
 means the player is on the edge of the board. By creating a fake
 block off the side of the board, it allows the coorect maths to be
 done without any modification.
 """
 matching_blocks = self.find_overlap()
 if not matching_blocks:
 return None
 matching_coordinates = {i: self.grid_main.relative_coordinates[i]
 for i in matching_blocks}
 #Create new value to handle 'off edge' cases
 if len(matching_coordinates.keys()) == 1:
 single_coordinate = matching_coordinates[matching_blocks[0]]
 new_location = (0, -self.grid_main.centre)
 #Workaround to handle the cases in the upper half
 if self.height < self.grid_main.segments:
 top_row_right = range(1, self.grid_main.segments)
 top_row_left = [i * self.grid_main.segments
 for i in range(1, self.grid_main.segments)]
 if self.width >= self.grid_main.segments:
 top_row_right.append(0)
 else:
 top_row_left.append(0)
 if matching_blocks[0] in top_row_left:
 new_location = (single_coordinate[0] - self.grid_main.x_offset,
 single_coordinate[1] + self.grid_main.y_offset)
 elif matching_blocks[0] in top_row_right:
 new_location = (single_coordinate[0] + self.grid_main.x_offset,
 single_coordinate[1] + self.grid_main.y_offset)
 matching_coordinates[-1] = new_location
 return sorted(matching_coordinates.items(), key=lambda (k, v): v[1])
 def calculate(self, debug=0):
 """Calculate which block ID the coordinates are on.
 This calculates the coordinates of the line between the two
 blocks, then depending on if a calculation results in a positive
 or negative number, it's possible to detect which block it falls
 on.
 By returning the (x1, y1) and (x2, y2) values, they can be linked
 with turtle to see it how it works under the hood.
 """
 all_blocks = self.find_block_coordinates()
 if all_blocks is None:
 return None
 highest_block = all_blocks[1][1]
 line_direction = self.width % 2 == self.height % 2
 if self.grid_main.segments % 2:
 line_direction = not line_direction
 #print self.width, self.height
 x1, y1 = (highest_block[0],
 highest_block[1] - self.grid_main.y_offset * 2)
 negative = int('-1'[not line_direction:])
 x2, y2 = (x1 + self.grid_main.x_offset * negative,
 y1 + self.grid_main.y_offset)
 sign = (x2 - x1) * (self.y - y1) - (y2 - y1) * (self.x - x1)
 sign *= negative
 #Return particular things when debugging
 if debug == 1:
 return (x1, y1), (x2, y2)
 if debug == 2:
 return sign
 selected_block = all_blocks[sign > 0][0]
 #If extra block was added, it was -1, so it is invalid
 if selected_block < 0:
 return None
 return selected_block + self.y_coordinate * pow(self.grid_main.segments, 2)
class CoordinateConvert(object):
 def __init__(self, width, height):
 self.width = width
 self.height = height
 self.centre = (self.width / 2, self.height / 2)
 def to_pygame(self, x, y):
 x = x - self.centre[0]
 y = self.centre[1] - y
 return (x, y)
 def to_canvas(self, x, y):
 x = x + self.centre[0]
 y = self.centre[1] - y
 return (x, y)
class GridDrawData(object):
 """Hold the relevant data for the grid, to allow it to be shown."""
 def __init__(self, length, segments, angle, padding=5):
 self.length = length
 self.segments = segments
 self.angle = angle
 self.padding = padding
 self._calculate()
 def _calculate(self):
 """Perform the main calculations on the values in __init__.
 This allows updating any of the values, such as the isometric
 angle, without creating a new class."""
 self.size_x = self.length * math.cos(math.radians(self.angle))
 self.size_y = self.length * math.sin(math.radians(self.angle))
 self.x_offset = self.size_x / self.segments
 self.y_offset = self.size_y / self.segments
 self.chunk_height = self.size_y * 2 + self.padding
 self.centre = (self.chunk_height / 2) * self.segments - self.padding / 2
 self.size_x_sm = self.size_x / self.segments
 self.size_y_sm = self.size_y / self.segments
 #self.segments_sq = pow(self.segments, 2)
 #self.grid_data_len = pow(self.segments, 3)
 #self.grid_data_range = range(self.grid_data_len)
 self.length_small = self.length / self.segments
 self.relative_coordinates = []
 position = (0, self.centre)
 for j in range(self.segments):
 checkpoint = position
 for i in range(self.segments):
 self.relative_coordinates.append(position)
 position = (position[0] + self.x_offset,
 position[1] - self.y_offset)
 position = (checkpoint[0] - self.x_offset,
 checkpoint[1] - self.y_offset)
 #Absolute coordinates for pygame
 chunk_coordinates = [(0, - i * self.chunk_height) for i in range(self.segments)]
 self.line_coordinates = [((self.size_x, self.centre - self.size_y),
 (self.size_x, self.size_y - self.centre)),
 ((-self.size_x, self.centre - self.size_y),
 (-self.size_x, self.size_y - self.centre)),
 ((0, self.centre - self.size_y * 2),
 (0, -self.centre))]
 for i in range(self.segments):
 chunk_height = -i * self.chunk_height
 self.line_coordinates += [((self.size_x, self.centre + chunk_height - self.size_y),
 (0, self.centre + chunk_height - self.size_y * 2)),
 ((-self.size_x, self.centre + chunk_height - self.size_y),
 (0, self.centre + chunk_height - self.size_y * 2))]
 for coordinate in self.relative_coordinates:
 start = (coordinate[0], chunk_height + coordinate[1])
 self.line_coordinates += [(start,
 (start[0] + self.size_x_sm, start[1] - self.size_y_sm)),
 (start,
 (start[0] - self.size_x_sm, start[1] - self.size_y_sm))]
class RunPygame(object):
 overlay_marker = '/'
 player_colours = [GREEN, LIGHTBLUE]
 empty_colour = YELLOW
 fps_idle = 15
 fps_main = 30
 fps_smooth = 120
 padding = (5, 10)
 overlay_width = 500
 option_padding = 2
 def __init__(self, C3DObject, screen_width=640, screen_height=860, default_length=200, default_angle=24):
 self.C3DObject = C3DObject
 self.width = screen_width
 self.height = screen_height
 self.length = default_length
 self.angle = default_angle
 self.player = int(not self.C3DObject.current_player)
 self.convert = CoordinateConvert(self.width, self.height)
 self.to_pygame = self.convert.to_pygame
 self.to_canvas = self.convert.to_canvas
 def _next_player(self):
 self.player = int(not self.player)
 def _previous_player(self):
 self._next_player()
 def play(self, p1=False, p2=Connect3D.bot_difficulty_default, allow_shuffle=True, end_when_no_points_left=False):
 #Setup pygame
 pygame.init()
 self.screen = pygame.display.set_mode((self.width, self.height))
 self.clock = pygame.time.Clock()
 pygame.display.set_caption('Connect 3D')
 background_colour = BACKGROUND
 self.backdrop = pygame.Surface((self.width, self.height))
 self.backdrop.set_alpha(196)
 self.backdrop.fill(WHITE)
 #Import the font
 self.font_file = 'Miss Monkey.ttf'
 try:
 pygame.font.Font(self.font_file, 0)
 except IOError:
 raise IOError('unable to load font - download from http://www.dafont.com/miss-monkey.font')
 self.font_lg = pygame.font.Font(self.font_file, 36)
 self.font_lg_size = self.font_lg.render('', 1, BLACK).get_rect()[3]
 self.font_md = pygame.font.Font(self.font_file, 24)
 self.font_md_size = self.font_md.render('', 1, BLACK).get_rect()[3]
 self.font_sm = pygame.font.Font(self.font_file, 18)
 self.font_sm_size = self.font_sm.render('', 1, BLACK).get_rect()[3]
 self.draw_data = GridDrawData(self.length,
 self.C3DObject.segments,
 self.angle,
 padding = self.angle / self.C3DObject.segments)
 #NOTE: These will all be cleaned up later, the grouping isn't great currently
 held_keys = {'angle': 0,
 'size': 0}
 #Store one off instructions to wipe later
 game_flags = {'clicked': False,
 'mouse_used': True,
 'quit': False,
 'recalculate': False,
 'reset': False,
 'hover': False,
 'flipped': False,
 'disable_background_clicks': False,
 'winner': None}
 #Store information that shouldn't be wiped
 game_data = {'players': [p1, p2],
 'overlay': 'options',
 'move_number': 0,
 'shuffle': [allow_shuffle, 3],
 'debug': False}
 #Store temporary things to update
 store_data = {'waiting': False,
 'waiting_start': 0,
 'shuffle_count': 0,
 'temp_fps': self.fps_main,
 'player_hover': None,
 'shuffle_hover': None,
 'new_game': False,
 'continue': False,
 'exit': False,
 'instructions': False,
 'debug_hover': None}
 block_data = {'id': None,
 'object': None,
 'taken': False}
 tick_data = {'old': 0,
 'new': 0,
 'update': 4, #How many ticks between each held key command
 'total': 0}
 mouse_data = pygame.mouse.get_pos()
 #How long to wait before accepting a move
 moving_wait = 0.5
 #For controlling how the angle and length of grid update
 angle_increment = 0.25
 angle_max = 35
 length_exponential = 1.1
 length_increment = 0.5
 length_multiplier = 0.01
 time_current = time.time()
 time_update = 0.01
 while True:
 self.clock.tick(store_data['temp_fps'] or self.fps_idle)
 tick_data['new'] = pygame.time.get_ticks()
 if game_flags['quit']:
 return self.C3DObject
 #Check if no spaces are left
 if '' not in self.C3DObject.grid_data:
 game_flags['winner'] = self.C3DObject._get_winning_player()
 #Need to come up with some menu for the winner
 #Print so it reminds me each time this happens
 print 'finish this'
 #Reset loop
 self.screen.fill(background_colour)
 if tick_data['total']:
 game_flags['recalculate'] = False
 game_flags['mouse_used'] = False
 game_flags['clicked'] = False
 game_flags['flipped'] = False
 game_flags['disable_background_clicks'] = False
 store_data['temp_fps'] = None
 tick_data['total'] += 1
 #Reinitialise the grid
 if game_flags['reset']:
 game_flags['reset'] = False
 game_data['move_number'] = 0
 game_data['shuffle'][0] = allow_shuffle
 game_data['players'] = (p1, p2)
 self.C3DObject = Connect3D(self.C3DObject.segments)
 game_flags['hover'] = None
 game_flags['recalculate'] = True
 store_data['waiting'] = False
 game_flags['winner'] = None
 if game_flags['hover'] is not None:
 if self.C3DObject.grid_data[game_flags['hover']] == self.overlay_marker:
 self.C3DObject.grid_data[game_flags['hover']] = ''
 game_flags['hover'] = None
 if game_data['overlay']:
 game_flags['disable_background_clicks'] = True
 #Delay each go
 if store_data['waiting']: 
 game_flags['disable_background_clicks'] = True
 if store_data['waiting_start'] < time.time():
 game_flags['recalculate'] = True
 attempted_move = self.C3DObject.make_move(store_data['waiting'][1], store_data['waiting'][0])
 if attempted_move is not None:
 game_data['move_number'] += 1
 self.C3DObject.update_score()
 store_data['shuffle_count'] += 1
 if store_data['shuffle_count'] >= game_data['shuffle'][1] and game_data['shuffle'][0]:
 store_data['shuffle_count'] = 0
 self.C3DObject.shuffle()
 game_flags['flipped'] = True
 else:
 game_flags['flipped'] = False
 else:
 self._next_player()
 print "Invalid move: {}".format(store_data['waiting'][0])
 store_data['waiting'] = False
 else:
 try:
 self.C3DObject.grid_data[store_data['waiting'][0]] = 9 - store_data['waiting'][1]
 except TypeError:
 print store_data['waiting'], ai_turn
 raise TypeError('trying to get to the bottom of this')
 #Run the AI
 ai_turn = None
 if game_data['players'][self.player] is not False:
 if not game_flags['disable_background_clicks'] and game_flags['winner'] is None:
 ai_turn = SimpleC3DAI(self.C3DObject, self.player, difficulty=game_data['players'][self.player]).calculate_next_move()
 #Event loop
 for event in pygame.event.get():
 if event.type == pygame.QUIT:
 return
 #Get single key presses
 if event.type == pygame.KEYDOWN:
 game_flags['recalculate'] = True
 if event.key == pygame.K_ESCAPE:
 if game_data['overlay'] is None:
 game_data['overlay'] = 'options'
 else:
 game_data['overlay'] = None
 if event.key == pygame.K_RIGHTBRACKET:
 self.C3DObject.segments += 1
 game_flags['reset'] = True
 if event.key == pygame.K_LEFTBRACKET:
 self.C3DObject.segments -= 1
 self.C3DObject.segments = max(1, self.C3DObject.segments)
 game_flags['reset'] = True
 if event.key == pygame.K_UP:
 held_keys['angle'] = 1
 if event.key == pygame.K_DOWN:
 held_keys['angle'] = -1
 if event.key == pygame.K_RIGHT:
 held_keys['size'] = 1
 if event.key == pygame.K_LEFT:
 held_keys['size'] = -1
 #Get mouse clicks
 if event.type == pygame.MOUSEBUTTONDOWN:
 game_flags['clicked'] = event.button
 game_flags['mouse_used'] = True
 if event.type == pygame.MOUSEMOTION:
 game_flags['mouse_used'] = True
 #Get held down key presses, but only update if enough ticks have passed
 key = pygame.key.get_pressed()
 update_yet = False
 if tick_data['new'] - tick_data['old'] > tick_data['update']:
 update_yet = True
 tick_data['old'] = pygame.time.get_ticks()
 if held_keys['angle']:
 if not (key[pygame.K_UP] or key[pygame.K_DOWN]):
 held_keys['angle'] = 0
 elif update_yet:
 self.draw_data.angle += angle_increment * held_keys['angle']
 game_flags['recalculate'] = True
 store_data['temp_fps'] = self.fps_smooth
 if held_keys['size']:
 if not (key[pygame.K_LEFT] or key[pygame.K_RIGHT]):
 held_keys['size'] = 0
 elif update_yet:
 length_exp = (max(length_increment,
 (pow(self.draw_data.length, length_exponential)
 - 1 / length_increment))
 * length_multiplier)
 self.draw_data.length += length_exp * held_keys['size']
 game_flags['recalculate'] = True
 store_data['temp_fps'] = self.fps_smooth
 #Update mouse information
 if game_flags['mouse_used'] or game_flags['recalculate']:
 game_flags['recalculate'] = True
 mouse_data = pygame.mouse.get_pos()
 x, y = self.to_pygame(*mouse_data)
 block_data['object'] = MouseToBlockID(x, y, self.draw_data)
 block_data['id'] = block_data['object'].calculate()
 block_data['taken'] = True
 if block_data['id'] is not None and ai_turn is None:
 block_data['taken'] = self.C3DObject.grid_data[block_data['id']] != ''
 #If mouse was clicked
 if not game_flags['disable_background_clicks']:
 if game_flags['clicked'] == 1 and not block_data['taken'] or ai_turn is not None:
 store_data['waiting'] = (ai_turn if ai_turn is not None else block_data['id'], self.player)
 store_data['waiting_start'] = time.time() + moving_wait
 self._next_player()
 #Highlight square
 if not block_data['taken'] and not store_data['waiting'] and not game_data['overlay']:
 self.C3DObject.grid_data[block_data['id']] = self.overlay_marker
 game_flags['hover'] = block_data['id']
 #Recalculate the data to draw the grid
 if game_flags['recalculate']:
 if not store_data['temp_fps']:
 store_data['temp_fps'] = self.fps_main
 self.draw_data.segments = self.C3DObject.segments
 self.draw_data.length = float(max((pow(1 / length_increment, 2) * self.draw_data.segments), self.draw_data.length, 2))
 self.draw_data.angle = float(max(angle_increment, min(89, self.draw_data.angle, angle_max)))
 self.draw_data._calculate()
 if game_flags['reset']:
 continue
 #Draw coloured squares
 for i in self.C3DObject.range_data:
 if self.C3DObject.grid_data[i] != '':
 chunk = i / self.C3DObject.segments_squared
 coordinate = list(self.draw_data.relative_coordinates[i % self.C3DObject.segments_squared])
 coordinate[1] -= chunk * self.draw_data.chunk_height
 square = [coordinate,
 (coordinate[0] + self.draw_data.size_x_sm,
 coordinate[1] - self.draw_data.size_y_sm),
 (coordinate[0],
 coordinate[1] - self.draw_data.size_y_sm * 2),
 (coordinate[0] - self.draw_data.size_x_sm,
 coordinate[1] - self.draw_data.size_y_sm),
 coordinate]
 #Player has mouse over square
 block_colour = None
 if self.C3DObject.grid_data[i] == self.overlay_marker:
 if game_data['players'][self.player] is False:
 block_colour = mix_colour(WHITE, WHITE, self.player_colours[self.player])
 #Square is taken by a player
 else:
 j = self.C3DObject.grid_data[i]
 #Square is being moved into, mix with red and white
 mix = False
 if isinstance(j, int) and j > 1:
 j = 9 - j
 moving_block = square
 mix = True
 block_colour = self.player_colours[j]
 if mix:
 block_colour = mix_colour(block_colour, GREY)
 if block_colour is not None:
 pygame.draw.polygon(self.screen,
 block_colour,
 [self.to_canvas(*corner)
 for corner in square],
 0)
 #Draw grid
 for line in self.draw_data.line_coordinates:
 pygame.draw.aaline(self.screen,
 BLACK,
 self.to_canvas(*line[0]),
 self.to_canvas(*line[1]),
 1)
 self._draw_score(game_flags['winner'])
 if game_data['debug']:
 self._draw_debug(block_data)
 if game_data['overlay']:
 store_data['temp_fps'] = self.fps_main
 header_padding = self.padding[1] * 5
 subheader_padding = self.padding[1] * 3
 self.blit_list = []
 self.rect_list = []
 self.screen.blit(self.backdrop, (0, 0))
 screen_width_offset = (self.width - self.overlay_width) / 2
 current_height = header_padding + self.padding[1]
 #Set page titles
 if game_data['overlay'] == 'instructions':
 title_message = 'Instructions/About'
 subtitle_message = ''
 elif game_data['move_number'] + bool(store_data['waiting']) and game_data['overlay'] == 'options':
 title_message = 'Options'
 subtitle_message = ''
 else:
 title_message = 'Connect 3D'
 subtitle_message = 'By Peter Hunt'
 title_text = self.font_lg.render(title_message, 1, BLACK)
 title_size = title_text.get_rect()[2:]
 self.blit_list.append((title_text, (self.padding[0] + screen_width_offset, current_height)))
 current_height += self.padding[1] + title_size[1]
 subtitle_text = self.font_md.render(subtitle_message, 1, BLACK)
 subtitle_size = subtitle_text.get_rect()[2:]
 self.blit_list.append((subtitle_text, (self.padding[0] + screen_width_offset, current_height)))
 current_height += subtitle_size[1]
 if subtitle_message:
 current_height += header_padding
 if game_data['overlay'] == 'options':
 #Player options
 players_unsaved = [p1, p2]
 players_original = list(game_data['players'])
 player_hover = store_data['player_hover']
 store_data['player_hover'] = None
 options = ['Human', 'Beginner', 'Easy', 'Medium', 'Hard', 'Extreme']
 for player in range(len(game_data['players'])):
 if players_unsaved[player] is False:
 players_unsaved[player] = -1
 else:
 players_unsaved[player] = get_bot_difficulty(players_unsaved[player], _debug=True)
 if players_original[player] is False:
 players_original[player] = -1
 else:
 players_original[player] = get_bot_difficulty(players_original[player], _debug=True)
 params = []
 for i in range(len(options)):
 params.append([i == players_unsaved[player] or players_unsaved[player] < 0 and not i,
 i == players_original[player] or players_original[player] < 0 and not i,
 [player, i] == player_hover])
 option_data = self._draw_options('Player {}: '.format(player),
 options,
 params,
 screen_width_offset,
 current_height)
 selected_option, options_size = option_data
 current_height += options_size
 if not player:
 current_height += self.padding[1]
 else:
 current_height += subheader_padding
 #Calculate mouse info
 if selected_option is not None:
 player_set = selected_option - 1
 if player_set < 0:
 player_set = False
 store_data['player_hover'] = [player, selected_option]
 if game_flags['clicked']:
 if not player:
 p1 = player_set
 else:
 p2 = player_set
 if not game_data['move_number']:
 game_data['players'] = (p1, p2) 
 #Ask whether to flip the grid
 options = ['Yes', 'No']
 params = []
 for i in range(len(options)):
 params.append([not i and allow_shuffle or i and not allow_shuffle,
 not i and game_data['shuffle'][0] or i and not game_data['shuffle'][0],
 not i and store_data['shuffle_hover'] or i and not store_data['shuffle_hover'] and store_data['shuffle_hover'] is not None])
 option_data = self._draw_options('Flip grid every 3 goes? ',
 ['Yes', 'No'],
 params,
 screen_width_offset,
 current_height)
 selected_option, options_size = option_data
 current_height += subheader_padding + options_size
 #Calculate mouse info
 store_data['shuffle_hover'] = None
 if selected_option is not None:
 store_data['shuffle_hover'] = not selected_option
 if game_flags['clicked']:
 allow_shuffle = not selected_option
 if not game_data['move_number']:
 game_data['shuffle'][0] = allow_shuffle
 #Toggle hidden debug option with ctrl+alt+d
 if not (not key[pygame.K_d] 
 or not (key[pygame.K_RCTRL] or key[pygame.K_LCTRL])
 or not (key[pygame.K_RALT] or key[pygame.K_LALT])):
 store_data['debug_hover']
 options = ['Yes', 'No']
 params = []
 for i in range(len(options)):
 params.append([not i and game_data['debug'] or i and not game_data['debug'],
 not i and game_data['debug'] or i and not game_data['debug'],
 not i and store_data['debug_hover'] or i and not store_data['debug_hover'] and store_data['debug_hover'] is not None])
 option_data = self._draw_options('Show debug info? ',
 ['Yes', 'No'],
 params,
 screen_width_offset,
 current_height)
 selected_option, options_size = option_data
 store_data['debug_hover'] = None
 if selected_option is not None:
 store_data['debug_hover'] = not selected_option
 if game_flags['clicked']:
 game_data['debug'] = not selected_option
 current_height += subheader_padding + options_size
 box_spacing = (header_padding + self.padding[1]) if game_data['move_number'] else (self.padding[1] + self.font_lg_size)
 box_height = [current_height]
 #Tell to restart game
 if game_data['move_number']:
 current_height += box_spacing
 restart_message = 'Restart game to apply settings.'
 restart_text = self.font_md.render(restart_message, 1, BLACK)
 restart_size = restart_text.get_rect()[2:]
 self.blit_list.append((restart_text, ((self.width - restart_size[0]) / 2, current_height)))
 current_height += header_padding
 #Continue button
 if self._pygame_button('Continue', 
 store_data['continue'], 
 current_height, 
 -1):
 store_data['continue'] = True
 if game_flags['clicked']:
 game_data['overlay'] = None
 else:
 store_data['continue'] = False
 box_height.append(current_height)
 current_height += box_spacing
 #Instructions button
 if self._pygame_button('Instructions' if game_data['move_number'] else 'Help', 
 store_data['instructions'], 
 box_height[0],
 0 if game_data['move_number'] else 1):
 store_data['instructions'] = True
 if game_flags['clicked']:
 game_data['overlay'] = 'instructions'
 else:
 store_data['instructions'] = False
 #New game button
 if self._pygame_button('New Game' if game_data['move_number'] else 'Start', 
 store_data['new_game'], 
 box_height[bool(game_data['move_number'])], 
 bool(game_data['move_number']) if game_data['move_number'] else -1):
 store_data['new_game'] = True
 if game_flags['clicked']:
 game_flags['reset'] = True
 game_data['overlay'] = None
 else:
 store_data['new_game'] = False
 #Quit button
 if self._pygame_button('Quit to Desktop' if game_data['move_number'] else 'Quit',
 store_data['exit'], 
 current_height):
 store_data['exit'] = True
 if game_flags['clicked']:
 game_flags['quit'] = True
 else:
 store_data['exit'] = False
 #Draw background
 background_square = (screen_width_offset, header_padding, self.overlay_width, current_height + self.padding[1] * 2)
 pygame.draw.rect(self.screen, WHITE, background_square, 0)
 pygame.draw.rect(self.screen, BLACK, background_square, 1)
 for i in self.rect_list:
 rect_data = [self.screen] + i
 pygame.draw.rect(*rect_data)
 for i in self.blit_list:
 self.screen.blit(*i)
 pygame.display.flip()
 def _pygame_button(self, message, hover, current_height, width_multipler=0):
 multiplier = 3
 #Set up text
 text_colour = BLACK if hover else GREY
 text_object = self.font_lg.render(message, 1, text_colour)
 text_size = text_object.get_rect()[2:]
 centre_offset = self.width / 10 * width_multipler
 text_x = (self.width - text_size[0]) / 2
 if width_multipler > 0:
 text_x += text_size[0] / 2
 if width_multipler < 0:
 text_x -= text_size[0] / 2
 text_x += centre_offset
 text_square = (text_x - self.option_padding * (multiplier + 1),
 current_height - self.option_padding * multiplier,
 text_size[0] + self.option_padding * (2 * multiplier + 2),
 text_size[1] + self.option_padding * (2 * multiplier - 1))
 self.blit_list.append((text_object, (text_x, current_height)))
 #Detect if mouse is over it
 x, y = pygame.mouse.get_pos()
 in_x = text_square[0] < x < text_square[0] + text_square[2]
 in_y = text_square[1] < y < text_square[1] + text_square[3]
 if in_x and in_y:
 return True
 return False
 def _draw_options(self, message, options, params, screen_width_offset, current_height):
 """Draw a list of options and check for inputs.
 Parameters:
 message (str): Text to display next to the options.
 options (list): Names of the options.
 params (list): Contains information on the options.
 It needs to have the same amount of records as
 options, with each of these being a list of 3 items.
 These are used to colour the text in the correct
 way.
 param[option][0] = new selection
 param[option][1] = currently active
 param[option][2] = mouse hoving over
 screen_width_offset (int): The X position to draw the
 text.
 current_height (int/float): The Y position to draw the
 text.
 """
 message_text = self.font_md.render(message, 1, BLACK)
 message_size = message_text.get_rect()[2:]
 self.blit_list.append((message_text, (self.padding[0] + screen_width_offset, current_height)))
 option_text = [self.font_md.render(i, 1, BLACK) for i in options]
 option_size = [i.get_rect()[2:] for i in option_text]
 option_square_list = []
 for i in range(len(options)):
 width_offset = (sum(j[0] + 2 for j in option_size[:i])
 + self.padding[0] * (i + 1) #gap between the start
 + message_size[0] + screen_width_offset)
 option_square = (width_offset - self.option_padding,
 current_height - self.option_padding,
 option_size[i][0] + self.option_padding * 2,
 option_size[i][1] + self.option_padding)
 option_square_list.append(option_square)
 #Set colours
 option_colours = list(SELECTION['Default'])
 param_order = ('Waiting', 'Selected', 'Hover')
 for j in range(len(params[i])):
 if params[i][j]:
 rect_colour, text_colour = list(SELECTION[param_order[j]])
 if rect_colour is not None:
 option_colours[0] = rect_colour
 if text_colour is not None:
 option_colours[1] = text_colour
 rect_colour, text_colour = option_colours
 self.rect_list.append([rect_colour, option_square])
 self.blit_list.append((self.font_md.render(options[i], 1, text_colour), (width_offset, current_height)))
 x, y = pygame.mouse.get_pos()
 selected_square = None
 for square in range(len(option_square_list)):
 option_square = option_square_list[square]
 in_x = option_square[0] < x < option_square[0] + option_square[2]
 in_y = option_square[1] < y < option_square[1] + option_square[3]
 if in_x and in_y:
 selected_square = square
 return (selected_square, message_size[1]) 
 def _format_output(self, text):
 """Format text to remove invalid characters."""
 left_bracket = ('[', '{')
 right_bracket = (']', '}')
 for i in left_bracket:
 text = text.replace(i, '(')
 for i in right_bracket:
 text = text.replace(i, ')')
 return text
 def _draw_score(self, winner):
 """Draw the title."""
 #Format scores
 point_marker = '/'
 p0_points = self.C3DObject.current_points[0]
 p1_points = self.C3DObject.current_points[1]
 p0_font_top = self.font_md.render('Player 0', 1, BLACK, self.player_colours[0])
 p1_font_top = self.font_md.render('Player 1', 1, BLACK, self.player_colours[1])
 p0_font_bottom = self.font_lg.render(point_marker * p0_points, 1, BLACK)
 p1_font_bottom = self.font_lg.render(point_marker * p1_points, 1, BLACK)
 p_size_top = p1_font_top.get_rect()[2:]
 p_size_bottom = p1_font_bottom.get_rect()[2:]
 if winner is None:
 go_message = "Player {}'s turn!".format(self.player)
 else:
 if len(winner) != 1:
 go_message = 'The game was a draw!'
 else:
 go_message = 'Player {} won!'.format(winner[0])
 go_font = self.font_lg.render(go_message, 1, BLACK)
 go_size = go_font.get_rect()[2:]
 self.screen.blit(go_font, ((self.width - go_size[0]) / 2, self.padding[1] * 3))
 self.screen.blit(p0_font_top, (self.padding[0], self.padding[1]))
 self.screen.blit(p1_font_top, (self.width - p_size_top[0] - self.padding[0], self.padding[1]))
 self.screen.blit(p0_font_bottom, (self.padding[0], self.padding[1] + p_size_top[1]))
 self.screen.blit(p1_font_bottom, (self.width - p_size_bottom[0] - self.padding[0], self.padding[1] + p_size_top[1]))
 def _draw_debug(self, block_data):
 """Show the debug information."""
 mouse_data = pygame.mouse.get_pos()
 x, y = self.to_pygame(*mouse_data)
 debug_coordinates = block_data['object'].calculate(debug=1)
 if debug_coordinates is not None:
 if all(i is not None for i in debug_coordinates):
 pygame.draw.aaline(self.screen,
 RED,
 pygame.mouse.get_pos(),
 self.to_canvas(*debug_coordinates[1]),
 1)
 pygame.draw.line(self.screen,
 RED,
 self.to_canvas(*debug_coordinates[0]),
 self.to_canvas(*debug_coordinates[1]),
 2)
 possible_blocks = block_data['object'].find_overlap()
 y_mult = str(block_data['object'].y_coordinate * self.C3DObject.segments_squared)
 if y_mult[0] != '-':
 y_mult = '+{}'.format(y_mult)
 info = ['DEBUG INFO',
 'FPS: {}'.format(int(round(self.clock.get_fps(), 0))),
 'Segments: {}'.format(self.C3DObject.segments),
 'Angle: {}'.format(self.draw_data.angle),
 'Side length: {}'.format(self.draw_data.length),
 'Coordinates: {}'.format(mouse_data),
 'Chunk: {}'.format((block_data['object'].width,
 block_data['object'].height,
 block_data['object'].y_coordinate)),
 'X Slice: {}'.format(block_data['object'].find_x_slice()),
 'Y Slice: {}'.format(block_data['object'].find_y_slice()),
 'Possible blocks: {} {}'.format(possible_blocks, y_mult),
 'Block weight: {}'.format(block_data['object'].calculate(debug=2)),
 'Block ID: {}'.format(block_data['object'].calculate())]
 font_render = [self.font_sm.render(self._format_output(i), 1, BLACK) for i in info]
 font_size = [i.get_rect()[2:] for i in font_render]
 for i in range(len(info)):
 message_height = self.height - sum(j[1] for j in font_size[i:])
 self.screen.blit(font_render[i], (0, message_height))
 #Format the AI text output
 ai_message = []
 for i in self.C3DObject.ai_message:
 #Split into chunks of 50 if longer
 message_len = len(i)
 message = [self._format_output(i[n * 50:(n + 1) * 50]) for n in range(round_up(message_len / 50.0))]
 ai_message += message
 font_render = [self.font_sm.render(i, 1, BLACK) for i in ai_message]
 font_size = [i.get_rect()[2:] for i in font_render]
 for i in range(len(ai_message)):
 message_height = self.height - sum(j[1] for j in font_size[i:])
 self.screen.blit(font_render[i], (self.width - font_size[i][0], message_height))

Background

The game is designed to be a 4x4x4 grid, but I didn't want to limit it, so everything I did had to be coded to work with any value (I'll refer to these as segments). When playing the game, use [ and ] to change the amount of segments, though be warned the AI will take exponentially longer, as each new segment creates 9x more processing. Also, you can change the side length and angle with the arrow keys.

Mouse coordinate to block ID

I initially drew the game with turtle, which was quite slow, but didn't require coordinates so was easy. However, converting the mouse coordinates into which block it was over wasn't, since the grid was isometric and not normal squares.

Turtle coordinates have (0, 0) in the middle, whereas pygame coordinates have (0, 0) in the top left, so as I wrote this function for turtle, there's an extra layer in place to convert the absolute coordinates from the mouse input into relative coordinates for this.

  1. I got which level the mouse was on, and then converted it to the top level, so that I didn't have to worry about getting the code working on all levels.
  2. I split the top level into 2D 'chunks' that were half the size of the blocks, so that there was one chunk for each connection between a block. I converted the mouse coordinates into which chunks they were in.
  3. With a lot of effort, I figured out 3 formulas (1 for X, 2 for Y) which would get all block IDs on those rows, for any amount of segments
  4. I'd compare the lists to find matches between the two, which in the middle of the grid, would result in 2 blocks. At the edge, it'd result in 1, so to get the next part correctly working, I had to make it come up with a fake block, so that it'd be able to compare the two.
  5. Using some formula I found for 'detecting if a point is over or under a line' (no idea what that is called), I find if the value is positive or negative, which depending on if the slope of the line is going up or down, can result in the correct block ID. I noticed that if both X and Y chunks are positive, or both are negative, the line between the two blocks slopes one way, and if one is positive and one is negative, the line slopes the other way (this is then reversed for an odd number of segments), so with that final tweak I got it working correctly.

Grid Draw Data

Since the only thing provided is number of segments, angle, line length and padding, each time any of this changes I need to recalculate virtually everything to keep the game working. This class takes care of that, and stores all the required information.

AI

The AI difficulty just determines how likely it is to not notice something, and how likely it is to change its priorities, but it still does all the calculations beforehand. When the game is in the early stages, the chance of not noticing an n-1 row is greatly reduced, since it's obvious to the human eye as well, and otherwise the AI just looks stupid.

It will look ahead 1 move to see if there's any rows of n-1, and if not, for every space in the grid, it'll look ahead 1 move to see if there's any rows of n-2.

If it is n-1, the first priority is to block the enemy, then gain points. This reverses for n-2, otherwise the AI only blocks and never does anything itself. If there is nothing found from this way, it'll determine the maximum number of points that can be gained from each block, and pick the best option (e.g. if you switch to an odd number of segments, the AI will always pick the middle block first).

Something I added yesterday was a bit of prediction, which works alongside the first method I mentioned, as I noticed the extreme AI was easy to trick. If you try trick the AI (as in you line up 2 points in 1 move, so if one is blocked you still get another point), it'll now notice that and block it. Likewise it can do the same to you. You can see this happening if you watch 2 extreme AI battle it out with each other.

Pygame

Since most of the time not much is going on, I made the frame rate variable, so it goes at 15 FPS if there is no movement (any less than that and you notice a delay when you try to move), 30 FPS if you are moving the mouse or have a menu open, and 120 FPS if you are resizing the grid.

The options overlay is drawn as part of the loop after everything else. I slightly modify the layout if the first move has been taken, and disable the instant updating of options (otherwise if you are losing you can temporarily activate the AI and win).

With the way I did the buttons, they look bad if two are next to each other and are a different size, so I tried my best to keep names a similar length (hence why 'help' changes to 'instructions').

asked Oct 22, 2015 at 16:56
\$\endgroup\$

1 Answer 1

5
\$\begingroup\$

1. MouseToBlockID

  1. Normally an instance of a class represents some thing, that is, a persistent object or data structure. But an instance of MouseToBlockID does not seem to represent any kind of thing. What you need here is a function that takes game coordinates and returns a block index.

    See Jack Diederich's talk "Stop Writing Classes".

    Since this function makes use of the attributes of the GridDrawData class, this would best be written as a method on that class:

    def game_to_block_index(self, gx, gy):
     """Return index of block at the game coordinates gx, gy, or None if
     there is no block at those coordinates."""
    
  2. The naming of variables needs work. When you have coordinates in three dimensions, it's conventional to call them "x", "y" and "z". But here you use the name y_coordinate for "z". That's bound to lead to confusion.

  3. The code is extraordinarily long and complex for what should be a simple operation. There are more than 200 lines in this class, but converting game coordinates to a block index should be a simple operation that proceeds as follows:

    Adjust gy so that it is relative to the origin of the bottom plane (the z=0 plane) rather than relative to the centre of the window:

    gy += self.centre
    

    Find z:

    z = int(gy // self.chunk_height)
    

    Adjust gy so that it is relative to the origin of its z-plane:

    gy -= z * self.chunk_height
    

    Reverse the isometric grid transform:

    dx = gx / self.size_x_sm
    dy = gy / self.size_y_sm
    x = int((dy - dx) // 2)
    y = int((dy + dx) // 2)
    

    Check that the result is in bounds, and encode position as block index:

    n = self.segments
    if 0 <= x < n and 0 <= y < n and 0 <= z < n:
     return n ** 3 - 1 - (x + n * (y + n * z))
    else:
     return None
    

    And that's it. Just twelve lines.

  4. It will be handy to encapsulate the transformation from block coordinates to block index in its own method:

    def block_index(self, x, y, z):
     """Return the block index corresponding to the block at x, y, z, or
     None if there is no block at those coordinates.
     """
     n = self.segments
     if 0 <= x < n and 0 <= y < n and 0 <= z < n:
     return n ** 3 - 1 - (x + n * (y + n * z))
     else:
     return None
    

    See below for how this can be used to simplify the drawing code.

  5. The encoding of block indexes is backwards, with (0, 0, 0) corresponding to block index 63 and (3, 3, 3) to block index 0. You'll see that had to write n ** 3 - 1 - (x + n * (y + n * z)) whereas x + n * (y + n * z) would be the more natural encoding.

2. GridDrawData

  1. The computation of game coordinates for the endpoints of the lines is verbose, hard to read, and hard to check:

    self.line_coordinates = [((self.size_x, self.centre - self.size_y),
     (self.size_x, self.size_y - self.centre)),
     ((-self.size_x, self.centre - self.size_y),
     (-self.size_x, self.size_y - self.centre)),
     ((0, self.centre - self.size_y * 2),
     (0, -self.centre))]
    

    What you need is a method that transforms block coordinates into game coordinates:

    def block_to_game(self, x, y, z):
     """Return the game coordinates corresponding to block x, y, z."""
     gx = (x - y) * self.size_x_sm
     gy = (x + y) * self.size_y_sm + z * self.chunk_height - self.centre
     return gx, gy
    

    Then you can compute all the lines using block coordinates, which is much easier to read and check:

    n = self.segments
    g = self.block_to_game
    self.lines = [(g(n, 0, n - 1), g(n, 0, 0)),
     (g(0, n, n - 1), g(0, n, 0)),
     (g(0, 0, n - 1), g(0, 0, 0))]
    for i, j, k in itertools.product(range(n+1), range(n+1), range(n)):
     self.lines.extend([(g(i, 0, k), g(i, n, k)),
     (g(0, j, k), g(n, j, k))])
    
  2. Using block_to_game you can avoid the need for relative_coordinates. Instead of:

    for i in self.C3DObject.range_data:
     if self.C3DObject.grid_data[i] != '':
     chunk = i / self.C3DObject.segments_squared
     coordinate = list(self.draw_data.relative_coordinates[i % self.C3DObject.segments_squared])
     coordinate[1] -= chunk * self.draw_data.chunk_height
     square = [coordinate,
     (coordinate[0] + self.draw_data.size_x_sm,
     coordinate[1] - self.draw_data.size_y_sm),
     (coordinate[0],
     coordinate[1] - self.draw_data.size_y_sm * 2),
     (coordinate[0] - self.draw_data.size_x_sm,
     coordinate[1] - self.draw_data.size_y_sm),
     coordinate]
    

    write:

    n = self.draw_data.segments
    g = self.draw_data.block_to_game
    for x, y, z in itertools.product(range(n), repeat=3):
     i = self.draw_data.block_index(x, y, z)
     if self.C3DObject.grid_data[i] != '':
     square = [g(x, y, z),
     g(x + 1, y, z),
     g(x + 1, y + 1, z),
     g(x, y + 1, z)]
    

3. RunPygame

  1. game_flags['recalculate'] gets set whenever game_flags['mouse_used'] is set. This means that the grid gets unnecessarily recalculated every time the mouse moves.

4. Isometric coordinates

Here's an explanation of how you can derive the isometric coordinate transformation and its inverse. Let's take the forward transformation first. You start with Cartesian coordinates \$x, y\$ and you want isometric coordinates \$ix, iy\$.

It's easiest to work this out if you introduce an intermediate set of coordinates: uniform isometric coordinates \$ux, uy\$ where the scale is the same in both dimensions (the diamonds are squares) and the height and width of each diamond is 1.

enter image description here

Now, the transformations are easy: to go from Cartesian coordinates to uniform isometric coordinates we use: $$ \eqalign{ ux &= {y + x \over 2} \cr uy &= {y - x \over 2} } $$ and then from uniform to plain isometric coordinates we use scale factors \$sx, sy\$: $$ \eqalign{ ix &= ux·sx \cr iy &= uy·sy } $$ Putting these together: $$ \eqalign{ ix &= (y + x){sx\over2} \cr iy &= (y - x){sy\over2} } $$ To reverse the transformation, treat these as simultaneous equations and solve for \$x\$ and \$y\$: $$ \eqalign{ x &= {ix\over sx} - {iy\over sy} \cr y &= {ix\over sx} + {iy\over sy}} $$

(These formulae aren't quite the same as the ones I used in the code above, but that's because your backwards block numbering scheme required me to swap \$x\$ and \$y\,ドル and because your size_x_sm is half of the scale factor \$sx\$.)

answered Nov 2, 2015 at 12:16
\$\endgroup\$
7
  • \$\begingroup\$ Hey thanks, I'm currently down south reading this off my phone so will just reply to a couple of points. As to #3, nice spot, I'd done that so when the grid is recalculated it updates the mouse data, forgot it'd work the other way round too. With #1.1, would you suggest for something so long just having them as separate functions? Putting them in a class for the purpose of grouping makes it feel a bit cleaner to me. I was using y for vertical because that's what Maya uses, and it avoids confusing me :) \$\endgroup\$ Commented Nov 2, 2015 at 15:56
  • \$\begingroup\$ My code was super long and complex since it seemed to be the only way to do it. I obviously can't test yours now (got a small phone so can't even read the code properly), but if it does the exact same job in 12 lines I'll be really amazed haha \$\endgroup\$ Commented Nov 2, 2015 at 15:58
  • \$\begingroup\$ 1. If you need to organize a bunch of functions, you can put them in a module. 2. If you're using y for the vertical coordinate, then the other two (horizontal) coordinates should be named x and z. \$\endgroup\$ Commented Nov 2, 2015 at 15:59
  • \$\begingroup\$ Just tried #1, and as well as using 10% of the lines, it's also around 8x more efficient, thanks so much (I also can't believe I spent an entire day on something apparently so simple to do, though I suppose mine looked cooler with the debug info enabled haha). I'll aim to stick with z as the vertical axis from now on too. As to #2, I'll properly check it out shortly, but line_coordinates was a messy fix to convert my turtle code to pygame, since I didn't know how else to calculate the end points from block ID. It is probably my least favourite bit, so again, thanks :D \$\endgroup\$ Commented Nov 3, 2015 at 8:25
  • \$\begingroup\$ I did some tests with your #2 and it really pains me to say (since you've coded it so well), both are quite a bit slower. The block_to_game function and vice versa is a really nice idea, but I think the amount of times the function is called causes the hit. #2.1 is around 2-3 times slower (with a slight bug that doubles up some lines though), and #2.2 is around 5x slower. Not a huge problem with the default settings, but if someone cranks up the number of segments, the performance drops quite fast. \$\endgroup\$ Commented Nov 5, 2015 at 8:48

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.