8
\$\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').

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

\$\endgroup\$
  • \$\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\$ – Peter Nov 2 '15 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\$ – Peter Nov 2 '15 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\$ – Gareth Rees Nov 2 '15 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\$ – Peter Nov 3 '15 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\$ – Peter Nov 5 '15 at 8:48

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.