diff --git a/app/gui/continue_btn.png b/app/gui/continue_btn.png new file mode 100644 index 0000000..a8add8b Binary files /dev/null and b/app/gui/continue_btn.png differ diff --git a/app/gui/load_btn.png b/app/gui/load_btn.png new file mode 100644 index 0000000..81ef992 Binary files /dev/null and b/app/gui/load_btn.png differ diff --git a/app/gui/run_ten_btn.png b/app/gui/run_ten_btn.png new file mode 100644 index 0000000..8666bf8 Binary files /dev/null and b/app/gui/run_ten_btn.png differ diff --git a/app/gui/start_btn.png b/app/gui/start_btn.png new file mode 100644 index 0000000..4e0a881 Binary files /dev/null and b/app/gui/start_btn.png differ diff --git a/app/saves/4blcok_save2.txt b/app/saves/4blcok_save2.txt new file mode 100644 index 0000000..24df700 Binary files /dev/null and b/app/saves/4blcok_save2.txt differ diff --git a/app/saves/4block_save.txt b/app/saves/4block_save.txt new file mode 100644 index 0000000..2aaccf6 Binary files /dev/null and b/app/saves/4block_save.txt differ diff --git a/app/saves/ai_save.txt b/app/saves/ai_save.txt new file mode 100644 index 0000000..d3505b9 Binary files /dev/null and b/app/saves/ai_save.txt differ diff --git a/app/saves/sl_save.txt b/app/saves/sl_save.txt new file mode 100644 index 0000000..8caf336 Binary files /dev/null and b/app/saves/sl_save.txt differ diff --git a/app/src/ai.py b/app/src/ai.py new file mode 100644 index 0000000..3218af5 --- /dev/null +++ b/app/src/ai.py @@ -0,0 +1,122 @@ +""" + Neural network module +""" + +import copy +import random +import numpy as np +from scipy.stats import truncnorm +from config import min_mutation_rate, mutation_chance + +# Generate random numbers +def truncated_normal(mean=0, sd=1, low=0, upp=10): + """ + returns a truncated normal + """ + return truncnorm((low - mean) / sd, (upp - mean) / sd, loc=mean, scale=sd) + +# changes value by random value in mutation range +# modulo to keep value in range +def mutate_by(value, mutation_rate): + """ + mutates a variable using a scale and a normal distribution + """ + if random.uniform(0, 1) < mutation_chance: + return value + np.random.normal(0, mutation_rate) + return value + +# Neural network class +class Nnetwork: + """ + Neural network class + contains all the layers and weights and clone and evaluate function + """ + def __init__(self, + numbers_of_nodes, + mutation_rate): + self.no_of_in_nodes = numbers_of_nodes[0] + self.no_of_out_nodes = numbers_of_nodes[1] + self.no_of_hidden_nodes = numbers_of_nodes[2] + self.no_of_hidden_layers = numbers_of_nodes[3] + self.mutation_rate = mutation_rate + self.create_weight_matrices() + + def create_weight_matrices(self): + """ + A method to initialize the weight matrices of the neural network + """ + # Input + rad = 1 / np.sqrt(self.no_of_in_nodes) + x = truncated_normal(mean=0, sd=1, low=-rad, upp=rad) + self.weights_in_hidden = x.rvs( + (self.no_of_hidden_nodes, self.no_of_in_nodes)) + + # Hidden layers + rad = 1 / np.sqrt(self.no_of_hidden_nodes) + self.hidden_layers = [] + for _ in range(self.no_of_hidden_layers): + x = truncated_normal(mean=0, sd=1, low=-rad, upp=rad) + weights_hidden_hidden = x.rvs( + (self.no_of_hidden_nodes, self.no_of_hidden_nodes)) + self.hidden_layers.append(weights_hidden_hidden) + + # Output + x = truncated_normal(mean=0, sd=1, low=-rad, upp=rad) + self.weights_hidden_out = x.rvs( + (self.no_of_out_nodes, self.no_of_hidden_nodes)) + + def clone(self): + """ + returns a mutated copy of the neural network + """ + network_copy = copy.deepcopy(self) + + # input weights + for i, weight in enumerate(network_copy.weights_in_hidden): + network_copy.weights_in_hidden[i] = mutate_by(weight, self.mutation_rate) + + # hiddens weights + for i, layer in enumerate(network_copy.hidden_layers): + for j, weight in enumerate(layer): + network_copy.hidden_layers[i][j] = mutate_by(weight, self.mutation_rate) + + # output weights + for i, weight in enumerate(network_copy.weights_hidden_out): + network_copy.weights_hidden_out[i] = mutate_by(weight, self.mutation_rate) + + # change mutation rate + network_copy.mutation_rate = abs(mutate_by(self.mutation_rate, self.mutation_rate)) + network_copy.mutation_rate = max(network_copy.mutation_rate, min_mutation_rate) + + return network_copy + + + def evaluate(self, input_vector): + """ + running the network with an input vector 'input_vector'. + 'input_vector' can be tuple, list or ndarray + """ + # Turn the input vector into a column vector: + input_vector = np.array(input_vector, ndmin=2).T + input_hidden = self.weights_in_hidden @ input_vector + + for _, layer in enumerate(self.hidden_layers): + input_hidden = layer @ input_hidden + + output_vector = self.weights_hidden_out @ input_hidden + return output_vector + + + def run(self, input_data): + """ + Returns the resulting action + decided by biggest value + """ + output_vector = self.evaluate(input_data) + maximum = np.amax(output_vector) + + for i, value in enumerate(output_vector): + if value == maximum: + return i + return 0 + \ No newline at end of file diff --git a/app/src/ai_tetris.py b/app/src/ai_tetris.py new file mode 100644 index 0000000..b3e3e26 --- /dev/null +++ b/app/src/ai_tetris.py @@ -0,0 +1,219 @@ +""" + The main app module + contains the genetic algorithm +""" + +import threading +import numpy as np +import pygame +import ai +import tetris +import gui +from button import Button +from config import s_width, s_height, start_btn_image, start_btn_position, start_btn_scale, load_btn_image, load_btn_position, load_btn_scale, run_ten_btn_image, run_ten_btn_position, run_ten_btn_scale, \ + number_of_networks, no_of_out_nodes, no_of_in_nodes, mutation_rate, num_of_parts, graph_position, graph_size, \ + save_file, col +import saves +from menu import show_menu + +# false positive +# pylint: disable=no-member + +def simulation_thread(neural_networks, variables, setting, dimensions): + """ + simulates a single game with parallel to others + """ + (index, show_gui, progress) = variables + neural_networks[index]["score"] = (0, 0) + for _ in range (3): + setting[5] += 1 + subsurface = window.subsurface(0, 0, s_width / 2, s_height) + (score, block_count) = tetris.game(subsurface, neural_networks[index]["network"].run, setting, dimensions, show_gui) # play one game + #neural_networks[index]["score"] += (neural_networks[index]["score"][0] + score, neural_networks[index]["score"][1] + block_count) + neural_networks[index]["score"] = (neural_networks[index]["score"][0] + float(score), neural_networks[index]["score"][1] + float(block_count)) + progress[0] += 1/3 + gui.draw_simulation_progress( + subsurface, progress[0], dimensions[0], dimensions[1], dimensions[2]) + +def calculate_score(score): + """ + Calculates the final score + """ + return score[0] + score[1] + #return score[0] + +# plays a game for each neural network and saves it's score +def simulate_generation(neural_networks, settings, block_size, play_width, play_height): + """ + runs many games in parallel and simulates a whole generation + """ + + # simulation progress counter + progress = [-1] + + # run parallel simulations + simulation_threads = [] + + # first network will show gui + simulation_threads.append(threading.Thread(target=simulation_thread, args=( + neural_networks, (0, True, progress), settings, (block_size, play_width, play_height)))) + simulation_threads[0].start() + + pool_size = int(number_of_networks / num_of_parts) * (num_of_parts - 1) + for i in range(pool_size): + simulation_threads.append(threading.Thread(target=simulation_thread, args=( + # list of neural networks + neural_networks, + # index, gui, progress bar + (number_of_networks - pool_size + i, False, progress), + # game settings + settings, (block_size, play_width, play_height)))) + simulation_threads[i + 1].start() + + simulation_threads[0].join() + # wait for all threads to finish + for i in range(pool_size): + # first network will show gui + simulation_threads[i + 1].join() + + neural_networks.sort(key=lambda item: calculate_score(item["score"])) + + return neural_networks[::-1] # return sorted from largest to smallest + + +def generate_networks(in_count, settings): + """ + generates brand new network and clones it to acheve desired amount + """ + # generate networks + neural_network = ai.Nnetwork((in_count, no_of_out_nodes, settings[3], settings[4]), + mutation_rate=mutation_rate) + + networks = [{ + "score": (0, 0), + "network": neural_network.clone() + } for i in range(number_of_networks)] + + print("networks are ready") + return networks + + +def evolve(networks, logs, blob, dimensions, buttons): + """ + runs a generation and then sorts and evolves the neural networks + """ + networks = simulate_generation( + networks, blob[1], dimensions[0], dimensions[1], dimensions[2]) + + # create offsprings to replenish population - roulette selection + roulette_wheel_scores = [calculate_score( + network["score"]) for network in networks] + roulette_wheel_networks = [network["network"] for network in networks] + + print([round(score, 2) for score in roulette_wheel_scores]) + + score_sum = sum(roulette_wheel_scores) + print(score_sum) + average = score_sum / number_of_networks + print("average score: " + str(average)) + print("generation: " + str(blob[0])) + logs[0].append(max(roulette_wheel_scores)) + logs[1].append(average) + + # kill the lower scoring half + networks = networks[0:int(number_of_networks / num_of_parts)] + + for _ in range(int(number_of_networks / num_of_parts)): + # find random parent + score_target = np.random.randint(0, score_sum) + for j, score in enumerate([x**4 for x in roulette_wheel_scores]): + score_target -= score + # generate offsprings + if score_target <= 0: + for _ in range(num_of_parts - 1): + networks.append({ + "score": (0, 0), + "network": roulette_wheel_networks[j].clone() + }) + break + + buttons[0].draw(window) + buttons[1].draw(window) + buttons[2].draw(window) + pygame.display.update() + gui.draw_graph(window, graph_position, graph_size, logs, (blob[0], dimensions[0])) + + # save the best ai + saves.save_network(networks[0], save_file) + return networks + + +def main(): + """ + main function + """ + settings = show_menu(window) + # (row, col, number_of_blocks, no_of_hidden_layers, no_of_hidden_nodes, rng_seed) = settings + block_size = int((600) / settings[0]) # size of block + # play window width; 300/10 = 30 width per block + # play window height; 600/20 = 20 height per block + + gui.draw_graph(window, graph_position, graph_size, [], (0, block_size)) + + # create button instances + # button to run one evolution cycle + start_img = pygame.image.load(start_btn_image).convert_alpha() + start_button = Button(start_btn_position, start_img, start_btn_scale) + start_button.draw(window) + + # button to load a neural network from save file + load_img = pygame.image.load(load_btn_image).convert_alpha() + load_button = Button(load_btn_position, load_img, load_btn_scale) + load_button.draw(window) + + # button to run ten generations + run_ten_img = pygame.image.load(run_ten_btn_image).convert_alpha() + run_ten_button = Button( + run_ten_btn_position, run_ten_img, run_ten_btn_scale) + run_ten_button.draw(window) + + pygame.display.update() + + max_score_log = [] + average_score_log = [] + + networks = generate_networks((no_of_in_nodes - col) + settings[1], settings) + generation = 0 + + run = True + while run: + # run once with gui + if start_button.click(): + networks = evolve(networks, [max_score_log, average_score_log], + (generation, settings), (block_size, settings[1] * block_size , settings[0] * block_size), (start_button, load_button, run_ten_button)) + generation += 1 + + # run ten times + if run_ten_button.click(): + for _ in range(10): + networks = evolve(networks, [max_score_log, average_score_log], + (generation, settings), (block_size, settings[1] * block_size , settings[0] * block_size), (start_button, load_button, run_ten_button)) + generation += 1 + + if load_button.click(): + networks = [saves.load_network(save_file)] * number_of_networks + print("network loaded") + generation = 0 + + # event handler + for event in pygame.event.get(): + # quit game + if event.type == pygame.QUIT: + run = False + + pygame.quit() + +if __name__ == '__main__': + window = pygame.display.set_mode((s_width, s_height)) + + main() diff --git a/app/src/button.py b/app/src/button.py new file mode 100644 index 0000000..5f43f6c --- /dev/null +++ b/app/src/button.py @@ -0,0 +1,44 @@ +""" + Button module +""" + +import pygame + +# button class +class Button(): + """ + Button class + """ + def __init__(self, position, image, scale): + width = image.get_width() + height = image.get_height() + self.image = pygame.transform.scale( + image, (int(width * scale), int(height * scale))) + self.rect = self.image.get_rect() + self.rect.topleft = position + self.clicked = False + + def click(self): + """ + returns true if the button was clicked + """ + action = False + # get mouse position + pos = pygame.mouse.get_pos() + + # check mouseover and clicked conditions + if self.rect.collidepoint(pos): + if pygame.mouse.get_pressed()[0] == 1 and not self.clicked: + self.clicked = True + action = True + + if pygame.mouse.get_pressed()[0] == 0: + self.clicked = False + + return action + + def draw(self, window): + """ + draw button on screen + """ + window.blit(self.image, (self.rect.x, self.rect.y)) diff --git a/app/src/config.py b/app/src/config.py new file mode 100644 index 0000000..a51e28a --- /dev/null +++ b/app/src/config.py @@ -0,0 +1,149 @@ +""" + Stores all important configuration data +""" +col = 8 # number of columns +row = 15 # number of rows + +s_width = 1000 # window width +s_height = 750 # window height +block_size = int((600) / row) # size of block +play_width = col * block_size # play window width; 300/10 = 30 width per block +play_height = row * block_size # play window height; 600/20 = 20 height per block +top_left_x = 0 # play area to left x +top_left_y = s_height - play_height - 50 # play area to left y + +fall_speed = 50 # determines the speed at which blocks fall +# how many inputs will the game wait for when running without graphical mode +turn_count = 1 +max_block_count = 13 # maximum amount of blocks allowed to fall to prevent infinite games + +no_of_in_nodes = col + 8 # number of input nodes - don't change +no_of_out_nodes = 5 # number of output nodes - don't change +no_of_hidden_nodes = 15 # number of hidden nodes in each hidden layer +no_of_hidden_layers = 2 # number of hidden layers +mutation_rate = 0.4 # starting mutation rate +mutation_chance = 0.1 # chance that the mutation will happen +min_mutation_rate = 0.1 # munimum mutation rate + +number_of_networks = 10 # how many neural networks will be created, has to be even +num_of_parts = 3 # into how many parts will be the old and new networks divided + +# Shapes +S = [['....', + '....', + '..00', + '.00.'], + ['....', + '..0.', + '..00', + '...0']] + +Z = [['....', + '....', + '.00.', + '..00'], + ['....', + '..0.', + '.00.', + '.0..']] + +I = [['..0.', + '..0.', + '..0.', + '..0.'], + ['....', + '0000', + '....', + '....']] + +O = [['....', + '....', + '.00.', + '.00.']] + +J = [['....', + '.0..', + '.000', + '....'], + ['....', + '..00', + '..0.', + '..0.'], + ['....', + '....', + '.000', + '...0'], + ['....', + '..0.', + '..0.', + '.00.']] + +L = [['....', + '...0', + '.000', + '....'], + ['....', + '..0.', + '..0.', + '..00'], + ['....', + '....', + '.000', + '.0..'], + ['....', + '.00.', + '..0.', + '..0.']] + +T = [['....', + '..0.', + '.000', + '....'], + ['....', + '..0.', + '..00', + '..0.'], + ['....', + '....', + '.000', + '..0.'], + ['....', + '..0.', + '.00.', + '..0.']] + +num_of_blocks = 4 +shape_list = [I, Z, L, O, J, S, T] +shape_colors = [(0, 0, 255), (255, 0, 0), (0, 255, 255), + (255, 255, 0), (255, 165, 0), (0, 255, 0), (128, 0, 128)] +border_color = (255, 255, 255) # border color +text_color = (255, 255, 255) # text color +background_color = (0, 0, 0) # background color + +graph_size = (s_width / 2, 300) # size of the graph +graph_position = (s_width / 2, top_left_y + play_height - + graph_size[1]) # position of the graph +graph_colors = [(255, 165, 0), (0, 0, 255)] + +save_file = "app/saves/ai_save.txt" # path to the ai save file + +caption = 'Tetris machine learning' + +start_btn_image = 'app/gui/start_btn.png' # path to the button image +# start button position +start_btn_position = (graph_position[0] * 1.05, graph_position[1] - 70) +start_btn_scale = 0.5 # start button scale + +load_btn_image = 'app/gui/load_btn.png' # path to the button image +# load button position +load_btn_position = (graph_position[0] * 1.70, graph_position[1] - 70) +load_btn_scale = start_btn_scale # load button scale + +run_ten_btn_image = 'app/gui/run_ten_btn.png' # path to the button image +# run ten times button position +run_ten_btn_position = (graph_position[0] * 1.38, graph_position[1] - 70) +run_ten_btn_scale = start_btn_scale # run ten button scale + +continue_btn_image = 'app/gui/continue_btn.png' # path to the button image +continue_btn_position = ((s_width) / 2 - 65, 570) # continue button position +continue_btn_scale = start_btn_scale # continue button scale diff --git a/app/src/gui.py b/app/src/gui.py new file mode 100644 index 0000000..7ac5464 --- /dev/null +++ b/app/src/gui.py @@ -0,0 +1,159 @@ +""" + Handles the application gui and draws the game +""" + +import pygame +from config import s_width, number_of_networks, num_of_parts, border_color, text_color, background_color, graph_colors, top_left_x, top_left_y, caption + +pygame.font.init() + + +def draw_text_middle(text, size, color, surface, data): + """ + Draws text in the middle of the window + """ + (top, play_width) = data + font = pygame.font.SysFont('Arial', size, bold=False, italic=True) + label = font.render(text, 1, color) + + surface.blit(label, ((s_width - play_width) // 2 + play_width/2 - (label.get_width()/2), + top_left_y + top - (label.get_height()/2))) + + +def draw_graph(surface, graph_position, graph_size, data, args): + """ + Draws a performance history graph + """ + (generation, block_size) = args + rectangle = (graph_position[0], graph_position[1], + graph_size[0], graph_size[1]) + surface.subsurface((graph_position[0], graph_position[1], + graph_size[0], graph_size[1] + 50)).fill(background_color) + # empty graph + if len(data) == 0: + # draw border + pygame.draw.rect(surface, border_color, rectangle, 4) + return + + maximum = max(data[0]) + # draw each dataset as separate line + for i, line in enumerate(data): + line = [0] + line + line = [(graph_position[0] + i * (graph_size[0] / (len(line) - 1)), graph_position[1] + + graph_size[1] - (graph_size[1] * point / maximum) * 0.95) for i, point in enumerate(line)] + pygame.draw.lines(surface, graph_colors[i], False, line, 2) + + # draw border + pygame.draw.rect(surface, border_color, rectangle, 4) + + # print data + font = pygame.font.SysFont('Arial', int(block_size / 2)) + text = "gen: " + str(generation) + " max: " + \ + str(round(data[0][-1], 2)) + " avg: " + str(round(data[1][-1], 2)) + label = font.render(text, 1, text_color) + surface.blit(label, ((graph_position[0] + graph_size[0] / 2) - ( + label.get_width() / 2), (graph_position[1] + graph_size[1]) + block_size / 2)) + + pygame.display.update( + rectangle[0], rectangle[1], rectangle[2], rectangle[3] + 40) + + +def draw_grid(surface, grid, block_size, play_width, play_height): + """ + Draws the lines of the grid for the game + """ + r = g = b = 0 + grid_color = (r, g, b) + + for i in range(len(grid)): + # draw grey horizontal lines + pygame.draw.line(surface, grid_color, (top_left_x, top_left_y + i * block_size), + (top_left_x + play_width, top_left_y + i * block_size)) + for j in range(len(grid[0])): + # draw grey vertical lines + pygame.draw.line(surface, grid_color, (top_left_x + j * block_size, top_left_y), + (top_left_x + j * block_size, top_left_y + play_height)) + + +def draw_simulation_progress(surface, progress, block_size, play_width, play_height): + """ + Prints the simulation progress + """ + font = pygame.font.SysFont('Arial', block_size) + text = str(int(progress / number_of_networks * 100 / (num_of_parts - 1) * num_of_parts)) + " %" + label = font.render(text, 1, text_color) + + rectangle = (top_left_x + play_width / 2) - (label.get_width() / 2), top_left_y + \ + play_height + 10, top_left_x + play_width, label.get_width() * 2 + pygame.draw.rect(surface, background_color, rectangle) + surface.blit(label, ((top_left_x + play_width / 2) - + (label.get_width() / 2), top_left_y + play_height + 10)) + pygame.display.update(rectangle) + + +def draw_next_shape(piece, surface, block_size, play_width): + """ + Draws the upcoming piece + """ + font = pygame.font.SysFont('Arial', int(block_size / 2)) + label = font.render('Next shape:', 1, text_color) + + start_x = top_left_x + play_width + 10 + start_y = top_left_y + block_size + + shape_format = piece.shape[piece.rotation % len(piece.shape)] + + for i, line in enumerate(shape_format): + row_list = list(line) + for j, column in enumerate(row_list): + if column == '0': + pygame.draw.rect(surface, piece.color, (start_x + j*block_size, + start_y + i*block_size, block_size, block_size), 0) + + surface.blit(label, (start_x, start_y - 30)) + + +def draw_window(surface, grid, block_size, dimensions, score=0): + """ + Draws the content of the window + """ + (play_width, play_height) = dimensions + surface.fill(background_color) # fill the surface with background color + + pygame.font.init() # initialise font + font = pygame.font.SysFont('Calibri', block_size * 2, bold=True) + # initialise 'Tetris' text with white + label = font.render('TETRIS', 1, text_color) + + # put on the center of the window + surface.blit(label, ((top_left_x + play_width / 2) - + (label.get_width() / 2), 30)) + + # current score + font = pygame.font.SysFont('Arial', int(block_size / 2)) + label = font.render('Score: ' + str(score), 1, text_color) + + start_x = top_left_x + play_width + 10 + start_y = top_left_y + block_size * 5 + + surface.blit(label, (start_x, start_y)) + + # draw content of the grid + for i, row in enumerate(grid): + for j, cell in enumerate(row): + # pygame.draw.rect() + # draw a rectangle shape + # rect(Surface, color, Rect, width=0) -> Rect + pygame.draw.rect(surface, cell, (top_left_x + j * block_size, + top_left_y + i * block_size, block_size, block_size), 0) + + # draw vertical and horizontal grid lines + draw_grid(surface, grid, block_size, play_width, play_height) + + # draw rectangular border around play area + pygame.draw.rect(surface, border_color, (top_left_x, + top_left_y, play_width, play_height), 4) + + +# initial config +pygame.display.set_caption(caption) diff --git a/app/src/inputbox.py b/app/src/inputbox.py new file mode 100644 index 0000000..228e0b1 --- /dev/null +++ b/app/src/inputbox.py @@ -0,0 +1,72 @@ +""" + Module which add an interactive textbox into pygame +""" + +# false positive +# pylint: disable=no-member + +import pygame as pg + +COLOR_INACTIVE = pg.Color('lightskyblue3') +COLOR_ACTIVE = pg.Color('dodgerblue2') +FONT = pg.font.Font(None, 32) + + +class InputBox: + """ + Box for data input + """ + def __init__(self, rect, text=''): + self.rect = pg.Rect(rect) + self.color = COLOR_INACTIVE + self.text = text + self.txt_surface = FONT.render(text, True, self.color) + self.active = False + + def handle_event(self, event): + """ + handles the button click event + """ + if event.type == pg.MOUSEBUTTONDOWN: + # If the user clicked on the input_box rect. + if self.rect.collidepoint(event.pos): + # Toggle the active variable. + self.active = not self.active + else: + self.active = False + # Change the current color of the input box. + self.color = COLOR_ACTIVE if self.active else COLOR_INACTIVE + if event.type == pg.KEYDOWN: + if self.active: + if event.key == pg.K_RETURN: + print(self.text) + self.text = '' + elif event.key == pg.K_BACKSPACE: + self.text = self.text[:-1] + else: + if str(event.unicode).isnumeric(): + self.text += event.unicode + # Re-render the text. + self.txt_surface = FONT.render(self.text, True, self.color) + + def update(self): + """ + Resize the box if the text is too long. + """ + width = max(200, self.txt_surface.get_width()+10) + self.rect.w = width + + def draw(self, screen): + """ + Draws the button on the screen + """ + screen.blit(self.txt_surface, (self.rect.x+5, self.rect.y+5)) + pg.draw.rect(screen, self.color, self.rect, 2) + + def value(self): + """ + Returns a numerical value of the box + """ + if self.text == '': + return 0 + return int(self.text) diff --git a/app/src/menu.py b/app/src/menu.py new file mode 100644 index 0000000..93199e7 --- /dev/null +++ b/app/src/menu.py @@ -0,0 +1,70 @@ +""" + Starting menu +""" +import pygame +from inputbox import InputBox +import gui +from button import Button +from config import background_color, text_color, s_width, row, col, no_of_hidden_layers, no_of_hidden_nodes, continue_btn_image, continue_btn_position, continue_btn_scale, play_width, num_of_blocks + +# false positive +# pylint: disable=no-member + +def show_menu(window): + """ + displays the starting menu and returns settings + """ + left_pos = (s_width - 200) / 2 + box_rows = InputBox((left_pos, 150, 140, 32), str(row)) + box_columns = InputBox((left_pos, 220, 140, 32), str(col)) + box_number_of_blocks = InputBox((left_pos, 290, 140, 32), str(num_of_blocks)) + box_layers = InputBox((left_pos, 360, 140, 32), str(no_of_hidden_layers)) + box_neurons = InputBox((left_pos, 430, 140, 32), str(no_of_hidden_nodes)) + box_rng_seed = InputBox((left_pos, 500, 140, 32), str(no_of_hidden_nodes)) + input_boxes = [box_rows, box_columns, box_number_of_blocks, + box_layers, box_neurons, box_rng_seed] + + # continue button + continue_img = pygame.image.load(continue_btn_image).convert_alpha() + continue_button = Button( + continue_btn_position, continue_img, continue_btn_scale) + continue_button.draw(window) + + done = False + while not done: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + pygame.quit() + done = True + for box in input_boxes: + box.handle_event(event) + + for box in input_boxes: + box.update() + + window.fill(background_color) + for box in input_boxes: + box.draw(window) + + if continue_button.click(): + done = True + + gui.draw_text_middle("SETTINGS", 30, text_color, window, (0, play_width)) + gui.draw_text_middle("rows", 20, text_color, window, (40, play_width)) + gui.draw_text_middle("columns", 20, text_color, + window, (110, play_width)) + gui.draw_text_middle("number of blocks", 20, + text_color, window, (180, play_width)) + gui.draw_text_middle("layers", 20, text_color, window, (250, play_width)) + gui.draw_text_middle("neurons", 20, text_color, + window, (320, play_width)) + gui.draw_text_middle("rng seed", 20, text_color, + window, (390, play_width)) + continue_button.draw(window) + + pygame.display.update() + + window.fill(background_color) + pygame.display.update() + + return [box.value() for box in input_boxes] diff --git a/app/src/saves.py b/app/src/saves.py new file mode 100644 index 0000000..35d6e3d --- /dev/null +++ b/app/src/saves.py @@ -0,0 +1,21 @@ +""" + Handles the saving and loading of objects +""" +import pickle + + +def save_network(obj, filename): + """ + Store the network in a save file + """ + with open(filename, 'wb') as outp: # Overwrites any existing file. + pickle.dump(obj, outp, pickle.HIGHEST_PROTOCOL) + + +def load_network(filename): + """ + Retrieve the network from a save file + """ + with open(filename, 'rb') as inp: # Overwrites any existing file. + network = pickle.load(inp) + return network diff --git a/app/src/test_ai.py b/app/src/test_ai.py new file mode 100644 index 0000000..e5ac5b1 --- /dev/null +++ b/app/src/test_ai.py @@ -0,0 +1,57 @@ +import pytest +from ai import mutate_by, Nnetwork +from math import isclose +import numpy as np + +# testing mutation +@pytest.mark.parametrize( + 'value, mutation_rate, expected, tol', + [ + (1, 0, 1000, 0), + (1, 0.1, 1000, 10), + (-10, 1, -10000, 100) + ]) +def test_normals(value, mutation_rate, expected, tol): + sum = 0 + for _ in range(1000): + sum += mutate_by(value, mutation_rate) + assert isclose(sum, expected, abs_tol=tol) + +@pytest.mark.parametrize( + 'value, mutation_rate, expected, tol', + [ + (1, 0, 999.999, 0), + (1, 0.1, 1000, 0.01), + (-10, 50, -10000, 1) + ]) +def test_normals2(value, mutation_rate, expected, tol): + sum = 0 + for _ in range(1000): + sum += mutate_by(value, mutation_rate) + assert not isclose(sum, expected, abs_tol=tol) + + +# test cloning (should make exact clone when zero mutations) +def test_cloning(): + network = Nnetwork([1, 1, 1, 1], 0) + clone = network.clone() + assert network.hidden_layers == clone.hidden_layers \ + and network.weights_hidden_out == clone.weights_hidden_out \ + and network.weights_in_hidden == clone.weights_in_hidden + +# should make a diferent clone +def test_cloning2(): + network = Nnetwork([100, 100, 100, 100], 10) + clone = network.clone() + assert not (np.array(network.hidden_layers) == np.array(clone.hidden_layers)).all() + + +# test inner logic +def test_evaluate(): + network = Nnetwork([1, 1, 1, 1], 0) + assert isclose(network.evaluate([1])[0], 0, rel_tol=1) + +# should only output left, right, down or rotate +def test_run(): + network = Nnetwork([4, 4, 100, 1], 1) + assert network.run([1, 6, -2, 0.1]) in [0, 1, 2, 3] \ No newline at end of file diff --git a/app/src/test_tetris.py b/app/src/test_tetris.py new file mode 100644 index 0000000..caa3fe9 --- /dev/null +++ b/app/src/test_tetris.py @@ -0,0 +1,95 @@ +import pytest +from tetris import Piece, game, get_holes, create_grid, valid_space, handle_input, get_highest_list +import pygame +from math import isclose + + +# testing tetrominos +@pytest.mark.parametrize( + 'x, y, shape, shapes, expected', + [ + (0, 0, ["0"], [["0"]], [(-2,-4)]), + (0, 0, [".0"], [[".0"]], [(-2,-3)]), + (0, 0, [".0"], [[".0"]], [(-2,-3)]), + (1, 2, [[".","0"]], [[[".","0"]]], [(-1,-1)]), + (0, 0, [['....', '.0..', '.000', '....']], + [[['....', '.0..', '.000', '....']],["0"]], + [(-1, -3), (-1, -2), (0, -2), (1, -2)]) + ]) +def test_cell_positions(x, y, shape, shapes, expected): + piece = Piece(x, y, shape, shapes) + assert piece.get_cell_positions() == expected + + + +# testing grid +@pytest.mark.parametrize( + 'locked_pos, row, col, expected', + { + ((0, 0, 0), 1, 1, (0, 0, 0)) + }) +def test_create_grid(locked_pos, row, col, expected): + assert create_grid(locked_pos, row, col) == [[expected]] + + + + +@pytest.fixture +def test_grid(): + return [[(0,0,0), (0,0,0)], [(0,0,0), (255,255,255)]] + +@pytest.fixture +def col(): + return 2 + +@pytest.fixture +def row(): + return 2 + +# testing positions +@pytest.fixture +def test_piece(): + return Piece(0, 0, [[".0",".0"]], [[[".0",".0"]]]) + +def test_valid_space(test_piece, test_grid): + assert valid_space(test_piece, test_grid, 2, 2) == True + +def test_invalid_space(test_piece, test_grid): + test_piece.x += 10 + test_piece.y += 10 + assert valid_space(test_piece, test_grid, 2, 2) == False + + +# testing game input +@pytest.mark.parametrize( + 'action_value, expected_x, expected_y', + { + (1, -1, -1), + (2, 1, 1), + (3, 0, 0), + (4, 0, 0), + }) +def test_handle_input(action_value, test_piece, test_grid, row, col, expected_x, expected_y): + pygame.display.set_mode((100, 100)) + handle_input(action_value, test_piece, test_grid, row, col) + assert test_piece.x == expected_x and test_piece.x == expected_y + + +# testing eval functions +def test_highest(col, test_grid): + assert get_highest_list(col, test_grid) == [0, 1] + +def test_holes(col, test_grid): + highest = get_highest_list(col, test_grid) + assert get_holes(col, highest, test_grid) == [0, -2] + + +# testing whole game +@pytest.fixture +def input_function(): + return lambda _ : 2 + +def test_game(input_function): + surface = pygame.display.set_mode((100, 100)) + score = game(surface, input_function, [9, 8, 2, 3, 5, 20], [5, 20, 20]) + assert isclose(score[0], 1.24, abs_tol=1e-9) and isclose(score[1], 0) \ No newline at end of file diff --git a/app/src/tetris.py b/app/src/tetris.py new file mode 100644 index 0000000..f7b5dbd --- /dev/null +++ b/app/src/tetris.py @@ -0,0 +1,331 @@ +""" + Independent tetris module + + tetriminos: + 0 - S - green + 1 - Z - red + 2 - I - cyan + 3 - O - yellow + 4 - J - blue + 5 - L - orange + 6 - T - purple +""" + +import sys +import random +from itertools import chain +import numpy as np +import pygame +import gui +from config import turn_count, max_block_count, shape_colors, s_width, s_height, shape_list + +# global variables + +# false positive +# pylint: disable=no-member + + +# class to represent each of the pieces +class Piece(): + """ + Tetromino object + """ + + def __init__(self, x, y, shape, shapes): + self.x = x + self.y = y + self.shape = shape + self.shapes = shapes + # choose color from the shape_color list + self.color = shape_colors[shapes.index(shape)] + self.rotation = 0 # chooses the rotation according to index + + def get_cell_positions(self): + """col + Converts the object type and rotation into an array of cell positions + """ + positions = [] + # get the desired rotated shape from piece + shape_format = self.shape[self.rotation % len(self.shape)] + + # i gives index; line gives string + for i, line in enumerate(shape_format): + row_list = list(line) # makes a list of char from string + # j gives index of char; column gives char + for j, column in enumerate(row_list): + if column == '0': + positions.append((self.x + j, self.y + i)) + + for i, pos in enumerate(positions): + # offset according to the input given with dot and zero + positions[i] = (pos[0] - 2, pos[1] - 4) + + return positions + + @staticmethod + def get_random_shape(shapes): + """ + Chooses a shape randomly from shapes list + """ + return Piece(5, 2, random.choice(shapes), shapes) + + +def create_grid(locked_pos, row, col): + """ + Initialise the grid + """ + grid = [[(0, 0, 0) for x in range(col)] + for y in range(row)] # grid represented rgb tuples + + # locked_positions dictionary + # (x,y):(r,g,b) + for y in range(row): + for x in range(col): + if (x, y) in locked_pos: + # get the value color (r,g,b) from the locked_positions dictionary using key (x,y) + color = locked_pos[(x, y)] + grid[y][x] = color # set grid position to color + + return grid + + +def valid_space(piece, grid, row, col): + """ + Checks if current position of piece in grid is valid + """ + # makes a 2D list of all the possible (x,y) + accepted_pos = [[(x, y) for x in range(col) if grid[y] + [x] == (0, 0, 0)] for y in range(row)] + # removes sub lists and puts (x,y) in one list; easier to search + accepted_pos = [x for item in accepted_pos for x in item] + + formatted_shape = piece.get_cell_positions() + + for pos in formatted_shape: + if pos not in accepted_pos: + if pos[1] >= 0: + return False + return True + + +def check_lost(positions): + """ + Check if the peice does not fit into the board + """ + for pos in positions: + if pos[1] < 1: + return True + return False + + +def clear_rows(grid, locked): + """ + clear a row when it is filled + """ + + # need to check if row is clear then shift every other row above one down + cleared = 0 + index = len(grid) - 1 + while index > 0: + grid_row = grid[index] + if (0, 0, 0) not in grid_row: + for j in range(len(grid_row)): + try: + # delete every locked element in the row + del locked[(j, index + cleared)] + except ValueError: + continue + + cleared += 1 # increase score + + # delete filled bottom row + # add another empty row on the top + # move down one step + for key in sorted(list(locked), key=lambda a: a[1])[::-1]: + x, y = key + if y < index + cleared: + new_key = (x, y + 1) # shift position to down + locked[new_key] = locked.pop(key) + + index -= 1 # decrease index + + return cleared + + +def user_input(): + """ + Handles all in game user input events + + 0 - Nothing + 1 - Left + 2 - Right + 3 - Down + 4 - Rotate + """ + + result_action = 0 + for event in pygame.event.get(): + if event.key == pygame.K_LEFT: + result_action = 1 + + elif event.key == pygame.K_RIGHT: + result_action = 2 + + elif event.key == pygame.K_DOWN: + result_action = 3 + + elif event.key == pygame.K_UP: + result_action = 4 + + return result_action + + +def handle_input(action_value, current_piece, grid, row, col): + """ + Handles inputed action value + + 0 - Nothing + 1 - Left + 2 - Right + 3 - Down + 4 - Rotate + """ + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + pygame.display.quit() + sys.exit(0) + + if action_value == 1: + current_piece.x -= 1 # move x position left + if not valid_space(current_piece, grid, row, col): + current_piece.x += 1 + + elif action_value == 2: + current_piece.x += 1 # move x position right + if not valid_space(current_piece, grid, row, col): + current_piece.x -= 1 + + elif action_value == 3: + current_piece.y += 1 # move y position down + while valid_space(current_piece, grid, row, col): + current_piece.y += 1 + current_piece.y -= 1 + + elif action_value == 4: + # rotate shape + current_piece.rotation = current_piece.rotation + \ + 1 % len(current_piece.shape) + if not valid_space(current_piece, grid, row, col): + current_piece.rotation = current_piece.rotation - \ + 1 % len(current_piece.shape) + +def get_highest_list(col, grid): + """ + get the position of the highest cells + """ + highest_list = [0] * col + for y, current_row in enumerate(grid[::-1]): + for x, cell in enumerate(current_row): + if cell > (0, 0, 0): + highest_list[x] = y + 1 + + return highest_list + +def get_holes(col, highest, grid): + """ + get the number of closed holes + """ + holes = [] + np_grid = np.array(grid) + for x in range(col): + holes.append(highest[x] - np.count_nonzero(np_grid[:,x])) + return holes + +def game(surface, input_function, settings, dimensions, graphics_mode=False): + """ + Main game function + """ + (row, col, number_of_blocks, _, _, rng_seed) = settings + (block_size, play_width, play_height) = dimensions + + shapes = shape_list[0:number_of_blocks] + + locked_positions = {} + change_piece = False + random.seed(rng_seed) + current_piece = Piece.get_random_shape(shapes) + next_piece = Piece.get_random_shape(shapes) + fall_count = 0 + score = 0 + height_score = 0 + block_count = 0 + last_holes = 0 + + while True: + + grid = create_grid(locked_positions, row, col) + fall_count += 1 + + # wait for given nuber of turns + if fall_count > turn_count: + fall_count = 0 + current_piece.y += 1 + height_score += current_piece.y * 0.01 + if not valid_space(current_piece, grid, row, col) and current_piece.y > 0: + current_piece.y -= 1 + change_piece = True + # give points for placing the block as low as possible + + # gather game data and pass it to the input function + # positions of all cells + # [highest cell in each column] + piece_pos = current_piece.get_cell_positions() + + #game_data = [shapes.index(current_piece.shape), + # current_piece.rotation, + # current_piece.x, + # current_piece.y] + game_data = list(chain.from_iterable(piece_pos)) + highest_list = get_highest_list(col, grid) + game_data += highest_list + + # get input + input_value = input_function(game_data) + # handle input + if not change_piece: + handle_input(input_value, current_piece, grid, row, col) + height_score += 0.005 if input_value == 4 else 0 + else: + holes = sum(get_holes(col, highest_list, grid)) + height_score -= (holes - last_holes) * 0.01 if holes > last_holes else 0 + last_holes = holes + + # draw the piece on the grid by giving color in the piece locations + for x, y in piece_pos: + if y >= 0: + grid[y][x] = current_piece.color + + if change_piece: # if the piece is locked + for pos in piece_pos: + p = (pos[0], pos[1]) + # add the key and value in the dictionary + locked_positions[p] = current_piece.color + current_piece = next_piece + next_piece = Piece.get_random_shape(shapes) + change_piece = False + + # increment score, give exponentialy more points for multiple rows cleared + score += (2 ** clear_rows(grid, locked_positions) - 1) * 2 + #block_count += 1 + + if graphics_mode: + gui.draw_window(surface, grid, block_size, (play_width, play_height), score) + gui.draw_next_shape(next_piece, surface, block_size, play_width) + pygame.display.update(0, 0, s_width / 2, s_height) + + # break to prevent infinite games + if check_lost(locked_positions) or block_count > max_block_count: + break + + return (height_score, score)