diff --git a/README.md b/README.md index 16b8778..17a271d 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,10 @@ Both game modes include: (Cement-4, PyConnect-4 + Dementris where you forget the tiles lmao) + + + + + + +move the cursor to the top cuz it makes more sense, make it point down. diff --git a/cli/cli_game.py b/cli/cli_game.py index c6a2772..39677d1 100644 --- a/cli/cli_game.py +++ b/cli/cli_game.py @@ -260,8 +260,6 @@ def evaluate_move(args): score = minimax(newBoard, depth - 1, float('-inf'), float('inf'), not maximising) return move, score -from multiprocessing import Pool - def cpu_move_provider(player, board): allowedMoves = [i for i, col in enumerate(board) if 'O' in col] diff --git a/gui/gui_game.py b/gui/gui_game.py index efa7398..19609c3 100644 --- a/gui/gui_game.py +++ b/gui/gui_game.py @@ -2,6 +2,7 @@ import pygame from pathlib import Path from button import Button from menu_manager import MenuManager +import random # consts ROOT_PATH = Path(__file__).parent @@ -15,15 +16,12 @@ WINDOW_WIDTH, WINDOW_HEIGHT = 768, 768 GRID_WIDTH = COLS * TILE_SIZE + (COLS - 1) * TILE_SPACING GRID_HEIGHT = ROWS * TILE_SIZE + (ROWS - 1) * TILE_SPACING -# grid origin cache for cursor drawing GRID_ORIGIN_X = None GRID_ORIGIN_Y = None -# cursor attributes CURSOR_HEIGHT = 18 CURSOR_MARGIN = 14 -# inits pygame.init() font = pygame.font.Font(ROOT_PATH / "Baloo2-Bold.ttf", 50) display = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) @@ -44,24 +42,86 @@ red_tile_hover = (220, 0, 0) yellow_tile = (255, 255, 0) yellow_tile_hover = (220, 220, 0) -# game state inits -tiles = [] -player = "red" +# --- Backend board --- +board = [["." for _ in range(ROWS)] for _ in range(COLS)] + +# game state +tiles: list[list[Button]] = [] +player = "r" board_full = False winner = None -# cursor state cursor_col = COLS // 2 - last_tile = None -# repeat cursor move key_held = None key_held_time = 0 -initial_delay = 200 # ms before repeat starts -repeat_interval = 100 # ms between repeats +initial_delay = 200 +repeat_interval = 100 -# tile + board logic +cpu = False + +# --- Board logic --- +def create_board(): + global board + board = [["." for _ in range(ROWS)] for _ in range(COLS)] + +def drop_tile(board, col, piece): + for r in reversed(range(ROWS)): + if board[col][r] == ".": + board[col][r] = piece + return r + return None + +def check_win(board, piece): + winCount = 4 + # Horizontal + for row in range(ROWS): + for col in range(COLS - winCount + 1): + if all(board[col + i][row] == piece for i in range(winCount)): + return [(col + i, row) for i in range(winCount)] + # Vertical + for col in range(COLS): + for row in range(ROWS - winCount + 1): + if all(board[col][row + i] == piece for i in range(winCount)): + return [(col, row + i) for i in range(winCount)] + # Diagonal \ + for col in range(COLS - winCount + 1): + for row in range(ROWS - winCount + 1): + if all(board[col + i][row + i] == piece for i in range(winCount)): + return [(col + i, row + i) for i in range(winCount)] + # Diagonal / + for col in range(COLS - winCount + 1): + for row in range(winCount - 1, ROWS): + if all(board[col + i][row - i] == piece for i in range(winCount)): + return [(col + i, row - i) for i in range(winCount)] + return None + +def is_board_full(board): + return all(board[c][0] != "." for c in range(COLS)) + +def lowest_empty_row(board, col): + for r in reversed(range(ROWS)): + if board[col][r] == ".": + return r + return None + +# --- Sync backend board → buttons --- +def sync_board_to_buttons(board, tiles): + for c in range(COLS): + for r in range(ROWS): + val = board[c][r] + if val == "r": + tiles[c][r].colour = red_tile + tiles[c][r].hover_colour = red_tile_hover + elif val == "y": + tiles[c][r].colour = yellow_tile + tiles[c][r].hover_colour = yellow_tile_hover + else: + tiles[c][r].colour = tile_colour + tiles[c][r].hover_colour = tile_hover + +# --- Tile + Button setup --- def create_tiles(): global tiles, GRID_ORIGIN_X, GRID_ORIGIN_Y tiles = [] @@ -76,98 +136,115 @@ def create_tiles(): for r in range(ROWS): x = start_x + c * (TILE_SIZE + TILE_SPACING) y = start_y + r * (TILE_SIZE + TILE_SPACING) + + # bind column to callback, ignore button argument + callback = lambda *_, col=c: play_move(col) + tile = Button( x, y, TILE_SIZE, TILE_SIZE, "", tile_colour, tile_hover, tile_text, - tile_press, None, 30, (c, r), + callback, None, 30, (c, r), rounding=30, outline_colour=(0,0,0), outline_width=5, hover_action=tile_hover_event ) col.append(tile) tiles.append(col) -def drop_tile(col_index): - column = tiles[col_index] - for r in reversed(range(ROWS)): - target_tile = column[r] - if target_tile.colour == tile_colour: - if player == "red": - target_tile.colour = red_tile - target_tile.hover_colour = red_tile_hover - else: - target_tile.colour = yellow_tile - target_tile.hover_colour = yellow_tile_hover - return r - -def check_win() -> list[Button, Button, Button, Button]: - global tiles, player +# --- Gameplay --- +def play_move(col_index, *_): + global board_full, winner, player, last_tile, cpu - colours = [yellow_tile, yellow_tile_hover] if player == "yellow" else [red_tile, red_tile_hover] - - winCount = 4 - for row in range(ROWS): - for col in range(COLS - winCount + 1): - if all(tiles[col + i][row].colour in colours for i in range(winCount)): - return [(col + i, row) for i in range(winCount)] - for col in range(COLS): - for row in range(ROWS - winCount + 1): - if all(tiles[col][row + i].colour in colours for i in range(winCount)): - return [(col, row + i) for i in range(winCount)] - for col in range(COLS - winCount + 1): - for row in range(ROWS - winCount + 1): - if all(tiles[col + i][row + i].colour in colours for i in range(winCount)): - return [(col + i, row + i) for i in range(winCount)] - for col in range(COLS - winCount + 1): - for row in range(winCount - 1, ROWS): - if all(tiles[col + i][row - i].colour in colours for i in range(winCount)): - return [(col + i, row - i) for i in range(winCount)] - -def is_board_full() -> bool: - for col in tiles: - for tile in col: - if tile.colour == tile_colour: - return False - return True - -# drop a tile in a column -def play_move(col_index: int): - global board_full, winner, player, last_tile if board_full or not tiles: return - row_dropped = drop_tile(col_index) + row_dropped = drop_tile(board, col_index, player) if row_dropped is None: return - if last_tile: - old_tile: Button = tiles[last_tile[0]][last_tile[1]] - old_tile.img = None last_tile = (col_index, row_dropped) - wins = check_win() + wins = check_win(board, player) if wins: winner = player for winX, winY in wins: - tile: Button = tiles[winX][winY] - tile.outline_width = 5 - tile.outline_colour = win_outline_colour + tiles[winX][winY].outline_width = 5 + tiles[winX][winY].outline_colour = win_outline_colour board_full = True - elif is_board_full(): + elif is_board_full(board): winner = None board_full = True else: - player = "yellow" if player == "red" else "red" - -def tile_press(tile: Button): - col_index, _row_index = tile.extra_data - play_move(col_index) + player = "y" if player == "r" else "r" def tile_hover_event(tile: Button): - col_index, _row_index = tile.extra_data + col_index, _ = tile.extra_data global cursor_col cursor_col = col_index -# buttons +def column_is_full(c: int) -> bool: + return all(board[c][r] != "." for r in range(ROWS)) + +# --- Ghost + Cursor --- +def draw_ghost_piece(display): + if not tiles or GRID_ORIGIN_X is None: + return + row = lowest_empty_row(board, cursor_col) + if row is None: + return + + base_color = red_tile if player == "r" else yellow_tile + ghost_color = (*base_color, 120) + + x = GRID_ORIGIN_X + cursor_col * (TILE_SIZE + TILE_SPACING) + y = GRID_ORIGIN_Y + row * (TILE_SIZE + TILE_SPACING) + + surf = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA) + pygame.draw.circle(surf, ghost_color, (TILE_SIZE//2, TILE_SIZE//2), TILE_SIZE//2 - 4) + pygame.draw.circle(surf, (255, 255, 255), (TILE_SIZE//2, TILE_SIZE//2), TILE_SIZE//2 - 4, 3) + + display.blit(surf, (x, y)) + +def draw_cursor(display): + if not tiles or GRID_ORIGIN_X is None: + return + base_color = red_tile if player == "r" else yellow_tile + color = base_color if not column_is_full(cursor_col) else (120, 120, 120) + col_center_x = GRID_ORIGIN_X + cursor_col * (TILE_SIZE + TILE_SPACING) + TILE_SIZE // 2 + y_top = GRID_ORIGIN_Y + GRID_HEIGHT + CURSOR_MARGIN + points = [ + (col_center_x, y_top), + (col_center_x - TILE_SIZE//3, y_top + CURSOR_HEIGHT), + (col_center_x + TILE_SIZE//3, y_top + CURSOR_HEIGHT), + ] + pygame.draw.polygon(display, color, points) + +# --- Draw game --- +def draw_game(display): + sync_board_to_buttons(board, tiles) + + if board_full: + if winner: + game_over_text.text = f"{'Red' if winner == 'r' else 'Yellow'} wins!" + game_over_text.text_colour = red_tile if winner == "r" else yellow_tile + else: + game_over_text.text = "It's a draw!" + game_over_text.text_colour = text_colour + game_over_button.draw(display) + else: + game_over_text.text = "Red's turn!" if player == "r" else "Yellow's turn!" + game_over_text.text_colour = red_tile if player == "r" else yellow_tile + + game_over_text.draw(display) + + if not board_full: + draw_cursor(display) + draw_ghost_piece(display) + + if last_tile: + img = pygame.image.load(str(ROOT_PATH / "star.png")) + tiles[last_tile[0]][last_tile[1]].img = img + +# --- Menus --- width, height = 280, 75 x = WINDOW_WIDTH / 2 - width / 2 y = WINDOW_HEIGHT / 2 - height / 2 @@ -204,100 +281,24 @@ game_over_text = Button(x, 50, width, height / 1.5, "text", bg_colour, (0, 0, 0), text_colour, None, font, 50, rounding=8) -# menu callbacks def start_game(mode): - if mode == "pvp": - global board_full, winner, player, cursor_col, last_tile - board_full = False - winner = None - player = "red" - cursor_col = COLS // 2 - last_tile = None - create_tiles() - menu_manager.change_menu("game") - elif mode == "cpu": - print("not implemented yet. give me money and i might make it fr") + global board_full, winner, player, cursor_col, last_tile, cpu + board_full = False + winner = None + player = "r" + cursor_col = COLS // 2 + last_tile = None + create_board() + create_tiles() + cpu = (mode == "cpu") + menu_manager.change_menu("game") def draw_settings(display): text_surface = font.render("No settings yet :(", True, text_colour) text_rect = text_surface.get_rect(center=(WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2 - 100)) display.blit(text_surface, text_rect) -# checks if a column is full -def column_is_full(c: int) -> bool: - return all(t.colour != tile_colour for t in tiles[c]) - -# helper for ghost, find the lowest empty tile -def lowest_empty_row(c: int): - for r in reversed(range(ROWS)): - if tiles[c][r].colour == tile_colour: - return r - -# ghost preview tile -def draw_ghost_piece(display): - if not tiles or GRID_ORIGIN_X is None: - return - row = lowest_empty_row(cursor_col) - if row is None: # column full - return - - # semi-transparent ghost color - base_color = red_tile if player == "red" else yellow_tile - ghost_color = (*base_color, 120) # RGBA - - # compute position - x = GRID_ORIGIN_X + cursor_col * (TILE_SIZE + TILE_SPACING) - y = GRID_ORIGIN_Y + row * (TILE_SIZE + TILE_SPACING) - - # create surface with alpha - surf = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA) - pygame.draw.circle(surf, ghost_color, (TILE_SIZE//2, TILE_SIZE//2), TILE_SIZE//2 - 4) - # white outline for ghost - pygame.draw.circle(surf, (255, 255, 255), (TILE_SIZE//2, TILE_SIZE//2), TILE_SIZE//2 - 4, 3) - - display.blit(surf, (x, y)) - -# draws the cursor -def draw_cursor(display): - if not tiles or GRID_ORIGIN_X is None: - return - base_color = red_tile if player == "red" else yellow_tile - color = base_color if not column_is_full(cursor_col) else (120, 120, 120) - col_center_x = GRID_ORIGIN_X + cursor_col * (TILE_SIZE + TILE_SPACING) + TILE_SIZE // 2 - y_top = GRID_ORIGIN_Y + GRID_HEIGHT + CURSOR_MARGIN - points = [ - (col_center_x, y_top), - (col_center_x - TILE_SIZE//3, y_top + CURSOR_HEIGHT), - (col_center_x + TILE_SIZE//3, y_top + CURSOR_HEIGHT), - ] - pygame.draw.polygon(display, color, points) - -def draw_game(display): - if board_full: - if winner: - game_over_text.text = f"{winner.title()} wins!" - if winner == "red": - game_over_text.text_colour = red_tile - elif winner == "yellow": - game_over_text.text_colour = yellow_tile - else: - game_over_text.text = "It's a draw!" - game_over_text.text_colour = text_colour - game_over_button.draw(display) - else: - game_over_text.text = "Red's turn!" if player == "red" else "Yellow's turn!" - game_over_text.text_colour = red_tile if player == "red" else yellow_tile - game_over_text.draw(display) - - if not board_full: - draw_cursor(display) - draw_ghost_piece(display) - - if last_tile: - img = pygame.image.load(str(ROOT_PATH / "star.png")) - tiles[last_tile[0]][last_tile[1]].img = img - -# menu manager init +# --- Menu manager --- menu_manager = MenuManager(display, bg_colour) menu_manager.register_menu("start", @@ -320,7 +321,7 @@ menu_manager.register_menu("game", menu_manager.change_menu("start") -# cursor movement + wrapping +# --- Cursor move --- def move_cursor(direction: str): global cursor_col if direction == "left": @@ -328,11 +329,123 @@ def move_cursor(direction: str): elif direction == "right": cursor_col = (cursor_col + 1) % COLS -# main loop +def evalWindow(window, piece): + opponent = "y" if piece == "r" else "r" + player_count = window.count(piece) + opponent_count = window.count(opponent) + empty_count = window.count(".") + score = 0 + + if player_count == 4: + score += 100 + elif player_count == 3 and empty_count == 1: + score += 50 + elif player_count == 2 and empty_count == 2: + score += 2 + + if opponent_count == 3 and empty_count == 1: + score -= 100 + return score + +def evalPositionForPlayer(board, piece): + score = 0 + # center column preference + center_col = [board[COLS//2][r] for r in range(ROWS)] + score += center_col.count(piece) * 3 + + # horizontal + for r in range(ROWS): + row_array = [board[c][r] for c in range(COLS)] + for c in range(COLS - 3): + window = row_array[c:c+4] + score += evalWindow(window, piece) + + # vertical + for c in range(COLS): + col_array = board[c] + for r in range(ROWS - 3): + window = col_array[r:r+4] + score += evalWindow(window, piece) + + # diagonals \ + for c in range(COLS - 3): + for r in range(ROWS - 3): + window = [board[c+i][r+i] for i in range(4)] + score += evalWindow(window, piece) + + # diagonals / + for c in range(COLS - 3): + for r in range(3, ROWS): + window = [board[c+i][r-i] for i in range(4)] + score += evalWindow(window, piece) + + return score + +def evalPosition(board): + return evalPositionForPlayer(board, "r") - evalPositionForPlayer(board, "y") + +# --- CPU --- +def cpu_move_provider(depth=4): + allowed = [c for c in range(COLS) if board[c][0] == "."] + if not allowed: + return None + + def minimax(bd, depth, alpha, beta, maximizing): + if check_win(bd, "r"): + return float('inf') + if check_win(bd, "y"): + return float('-inf') + if is_board_full(bd) or depth == 0: + return evalPosition(bd) + + allowed = [c for c in range(COLS) if lowest_empty_row(bd, c) is not None] + + if maximizing: + maxEval = float('-inf') + for col in allowed: + row = lowest_empty_row(bd, col) + bd[col][row] = "r" + eval_score = minimax(bd, depth-1, alpha, beta, False) + bd[col][row] = "." + maxEval = max(maxEval, eval_score) + alpha = max(alpha, eval_score) + if beta <= alpha: + break + return maxEval + else: + minEval = float('inf') + for col in allowed: + row = lowest_empty_row(bd, col) + bd[col][row] = "y" + eval_score = minimax(bd, depth-1, alpha, beta, True) + bd[col][row] = "." + minEval = min(minEval, eval_score) + beta = min(beta, eval_score) + if beta <= alpha: + break + return minEval + + best_score = float('-inf') if player == "r" else float('inf') + best_col = random.choice(allowed) + + for col in allowed: + row = lowest_empty_row(board, col) + board[col][row] = player + score = minimax(board, depth-1, float('-inf'), float('inf'), player=="y") + board[col][row] = "." + if player == "r" and score > best_score: + best_score = score + best_col = col + elif player == "y" and score < best_score: + best_score = score + best_col = col + return best_col + +# --- Main loop --- if __name__ == "__main__": running = True while running: - dt = clock.tick(TARGET_FPS) # time since last frame + dt = clock.tick(TARGET_FPS) for event in pygame.event.get(): if event.type == pygame.QUIT: @@ -360,7 +473,13 @@ if __name__ == "__main__": menu_manager.handle_event(event) - # repeat held key + # CPU turn + if cpu and player == 'y': + move = cpu_move_provider() + if move is not None: + play_move(move) + + # Repeat key hold if key_held: key_held_time += dt if key_held_time > initial_delay: diff --git a/settings.json b/settings.json deleted file mode 100644 index e8a32d9..0000000 --- a/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "display_mode": "coloured_background", - "cpu_search_depth": 6 -} \ No newline at end of file