# Pygame version of my 3D Tic Tac Toe/Connect 4

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 - 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)

for i in range(starting_point, self.grid_main.segments):
n_multiple = self.grid_main.segments * i
if 0 < i < self.grid_main.segments:
else:
break

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
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._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.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
overlay_width = 500

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:
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,

#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
self.blit_list = []
self.rect_list = []
self.screen.blit(self.backdrop, (0, 0))
screen_width_offset = (self.width - self.overlay_width) / 2

#Set page titles
if game_data['overlay'] == 'instructions':
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:]

subtitle_text = self.font_md.render(subtitle_message, 1, BLACK)
subtitle_size = subtitle_text.get_rect()[2:]

current_height += subtitle_size[1]
if subtitle_message:

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:
else:

#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

#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

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)))

#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
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),
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:]

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_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))

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').

### 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.

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$.)

• 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 :) – Peter Nov 2 '15 at 15:56
• 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 – Peter Nov 2 '15 at 15:58
• 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. – Gareth Rees Nov 2 '15 at 15:59
• 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 – Peter Nov 3 '15 at 8:25
• 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. – Peter Nov 5 '15 at 8:48