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:
With debug enabled:
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.
- 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.
- 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.
- 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
- 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.
- 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').