Compare commits

...

10 Commits

9 changed files with 298 additions and 32 deletions

View File

@ -1,16 +1,39 @@
# connect-4 # PyConnect-4
This will eventually become a connect-4 game written in Python with an AI opponent you can play. **PyConnect-4** is, as the name suggests, a Python implementation of the classic Connect-4 game.
It comes with **two game modes**:
I will make and train the AI myself. - **CLI**: Play directly in the terminal.
- **GUI**: Play using a PyGame graphical interface.
I might make a GUI in Pygame, depends if i cbs ---
todo list: ## Features
~~player vs player on the same device~~, player vs player lan, and player vs computer
pvp lan todo: Both game modes include:
(It's in maintenance rn cuz of 1,000,000 serious vulns)
remember the last ip you played against. remember ip input with a name? - Player vs Player (on the same device)
stop multiple ppl joining one game (or add spectators) - ~~Player vs Player (LAN)~~ *(currently under maintenance)*
make it so when ppl leave, the game ends instead of hanging or crashing. - Player vs CPU (uses a strong minimax algorithm)
- Settings system (adjust display mode and CPU strength)
---
## To-Do List
### Player vs Player (LAN)
- Take it out of maintenance.
- Remember the last IP you played against:
- Optionally store IP by hostname.
- Keep a file with all previously played hostnameIP pairs to allow connecting using the computers hostname like `t495` instead of `192.168.1.xxx`.
- Prevent more than 2 people from joining a single game (or consider adding a spectator mode).
- Make sure the game ends properly when players leave, instead of freezing or crashing.
### Player vs CPU
- Fix priority issues: sometimes the CPU prefers a vertical 3-in-a-row instead of completing a horizontal win.
### GUI
- Make all the menus a big ol dict for cleanliness

View File

@ -83,19 +83,14 @@ def printBoard(board):
============================={C.END}""" ============================={C.END}"""
bottom = f"{C.BOLD}==1===2===3===4===5===6===7=={C.END}" bottom = f"{C.BOLD}==1===2===3===4===5===6===7=={C.END}"
# print(f""" {C.BOLD}CONNECT FOUR
# ============================={C.END}
# {'\n'.join(rows)}
# {C.BOLD}==1===2===3===4===5===6===7=={C.END}""")
print(f"{top}\n{'\n'.join(rows)}\n{bottom}") print(f"{top}\n{'\n'.join(rows)}\n{bottom}")
def getIntInput(prompt, board=None): def getIntInput(prompt, board=None):
while True: while True:
inp = input(prompt) inp = input(prompt)
try: try:
inp = int(inp) inp = int(inp)
if not 1 <= inp <= 8: if not 1 <= inp <= 7:
raise ValueError raise ValueError
return inp return inp
except ValueError: except ValueError:
@ -124,13 +119,18 @@ def checkWin(board, player):
if all(board[col + i][row - i] == player for i in range(winCount)): if all(board[col + i][row - i] == player for i in range(winCount)):
return [(col + i, row - i) for i in range(winCount)] return [(col + i, row - i) for i in range(winCount)]
def checkFull(board):
if all('O' not in col for col in board):
return True
return False
def isTerminalNode(board): def isTerminalNode(board):
if checkWin(board, 'R'): if checkWin(board, 'R'):
return "WinX" return "WinX"
elif checkWin(board, 'Y'): elif checkWin(board, 'Y'):
return "WinY" return "WinY"
elif checkFull(board):
if all('O' not in col for col in board):
return "Draw" return "Draw"
return False return False
@ -253,7 +253,15 @@ def cpu_move_provider(player, board):
best_score = float('-inf') if player == 'R' else float('inf') best_score = float('-inf') if player == 'R' else float('inf')
best_move = None best_move = None
search_depth = 5 try:
with open("settings.json", "r") as f:
settings = json.load(f)
print(f"Settings: {settings}")
search_depth = settings.get("cpu_search_depth", 5)
print(f"search depth: {search_depth}")
except (FileNotFoundError, json.JSONDecodeError):
search_depth = 5
maximising = True if player == 'R' else False maximising = True if player == 'R' else False
for move in allowedMoves: for move in allowedMoves:
@ -288,7 +296,6 @@ def play_game(player1_get_move, player2_get_move):
clear() clear()
printBoard(board) printBoard(board)
# Get column from correct player
if player == 'R': if player == 'R':
col = player1_get_move(player, board) col = player1_get_move(player, board)
else: else:
@ -310,6 +317,13 @@ def play_game(player1_get_move, player2_get_move):
print(f"{colourTile(player)} won!") print(f"{colourTile(player)} won!")
input("Press ENTER to return to the menu.") input("Press ENTER to return to the menu.")
break break
if checkFull(board):
clear()
printBoard(board)
print("Its a draw!")
input("Press ENTER to return to the menu.")
break
player = 'Y' if player == 'R' else 'R' player = 'Y' if player == 'R' else 'R'
@ -321,12 +335,12 @@ def play_local_pvp():
def play_lan_server(): def play_lan_server():
print("PvP LAN is in maintenance due to exploits.!") print("PvP LAN is in maintenance due to exploits.!")
input("Press Enter to return to menu...") input("Press ENTER to return to menu...")
return return
def play_lan_client(): def play_lan_client():
print("PvP LAN is in maintenance due to exploits.!") print("PvP LAN is in maintenance due to exploits.!")
input("Press Enter to return to menu...") input("Press ENTER to return to menu...")
return return
def play_vs_computer(): def play_vs_computer():
@ -338,7 +352,7 @@ def play_vs_computer():
raise ValueError raise ValueError
break break
except ValueError: except ValueError:
print("Enter 'r', 'red', 'y' or 'yellow'.") print("ENTER 'r', 'red', 'y' or 'yellow'.")
if inp in ["r", "red"]: if inp in ["r", "red"]:
play_game(local_move_provider, cpu_move_provider) play_game(local_move_provider, cpu_move_provider)
@ -357,7 +371,8 @@ def edit_settings():
# Default settings if no file exists # Default settings if no file exists
default_settings = { default_settings = {
"display_mode": "coloured_text" # options: coloured_text, coloured_background, emojis "display_mode": "coloured_text", # options: coloured_text, coloured_background, emojis
"cpu_search_depth": 5 # options: 1-9
} }
# Load existing settings # Load existing settings
@ -377,12 +392,13 @@ def edit_settings():
with open(settings_file, "w") as f: with open(settings_file, "w") as f:
json.dump(settings, f, indent=4) json.dump(settings, f, indent=4)
print("Settings saved.") print("Settings saved.")
input("Press Enter to return to main menu...") input("Press ENTER to return to main menu...")
while True: while True:
clear() clear()
print("=== Settings Menu ===") print("=== Settings Menu ===")
print("1. Display Mode") print("1. Display Mode")
print("2. CPU Search Depth")
print("--------------------") print("--------------------")
print("S. Save and Exit") print("S. Save and Exit")
print("E. Exit without Saving") print("E. Exit without Saving")
@ -414,7 +430,31 @@ def edit_settings():
settings["display_mode"] = modes[int(sub_choice) - 1][0] settings["display_mode"] = modes[int(sub_choice) - 1][0]
print(f"Display mode set to {settings['display_mode']}") print(f"Display mode set to {settings['display_mode']}")
else: else:
input("Invalid choice. Press Enter to try again...") input("Invalid choice. Press ENTER to try again...")
elif choice == "2":
# CPU Search Depth submenu
while True:
clear()
print("=== CPU Search Depth ===")
print(f"Depth: {C.BOLD}{settings["cpu_search_depth"]}{C.END}")
print("B. Go Back")
sub_choice = input("Choose a value 1-9, or the use + / - keys: ").strip()
if sub_choice.lower() == 'b':
break
elif sub_choice in ["1","2","3","4","5","6","7","8","9"]:
settings["cpu_search_depth"] = sub_choice
elif sub_choice in ['+', '-']:
settings["cpu_search_depth"] = eval(f"{settings["cpu_search_depth"]} {sub_choice}1")
if settings["cpu_search_depth"] > 9:
settings["cpu_search_depth"] = 9
elif settings["cpu_search_depth"] < 1:
settings["cpu_search_depth"] = 1
else:
input("Invalid choice. Press ENTER to try again...")
elif choice == "save" or choice == "s": elif choice == "save" or choice == "s":
save_settings() save_settings()
@ -429,7 +469,7 @@ def edit_settings():
return return
else: else:
input("Invalid choice. Press Enter to try again...") input("Invalid choice. Press ENTER to try again...")
while True: while True:
clear() clear()
@ -457,4 +497,4 @@ while True:
elif choice == "6": elif choice == "6":
break break
else: else:
input("Invalid choice. Press Enter to try again...") input("Invalid choice. Press ENTER to try again...")

4
cli/settings.json Normal file
View File

@ -0,0 +1,4 @@
{
"display_mode": "emojis",
"cpu_search_depth": 5
}

BIN
gui/Baloo2-Bold.ttf Normal file

Binary file not shown.

31
gui/button.py Normal file
View File

@ -0,0 +1,31 @@
import pygame
class Button:
def __init__(self, x, y, width, height, text, color, hover_color, text_color, action, font, font_size, extra_data = None, rounding = 0):
self.rect = pygame.Rect(x, y, width, height)
self.text = text
self.color = color
self.hover_color = hover_color
self.text_color = text_color
self.current_color = color
self.action = action
self.font = pygame.font.Font(font, font_size)
self.extra_data = extra_data
self.rounding = rounding
def draw(self, screen):
pygame.draw.rect(screen, self.current_color, self.rect, border_radius=self.rounding)
text_surface = self.font.render(self.text, True, self.text_color)
text_rect = text_surface.get_rect(center=self.rect.center)
screen.blit(text_surface, text_rect)
def handle_event(self, event):
if event.type == pygame.MOUSEMOTION:
if self.rect.collidepoint(event.pos):
self.current_color = self.hover_color
else:
self.current_color = self.color
elif event.type == pygame.MOUSEBUTTONDOWN:
if self.rect.collidepoint(event.pos):
self.action(self)

140
gui/game.py Normal file
View File

@ -0,0 +1,140 @@
# imports
import pygame
from button import Button
from menu_manager import MenuManager
# consts
WINDOW_SIZE = (768, 768)
TARGET_FPS = 60
COLS, ROWS = 7, 6
TILE_SIZE, TILE_SPACING = 50, 20
GRID_WIDTH = COLS * TILE_SIZE + (COLS - 1) * TILE_SPACING
GRID_HEIGHT = ROWS * TILE_SIZE + (ROWS - 1) * TILE_SPACING
# init the pygame
pygame.init()
font = pygame.font.Font("Baloo2-Bold.ttf", 40)
display = pygame.display.set_mode(WINDOW_SIZE)
clock = pygame.time.Clock()
# variables
tiles = []
menu_manager = MenuManager(display, (30, 30, 40)) # background color
# colorss
primary_color = (70, 130, 180) # button background
hover_color = (51, 102, 145) # button hover
text_color = (245, 245, 245) # button text
tile_color = (200, 200, 200) # tile main
tile_hover = (170, 170, 170) # tile hover
tile_text = (50, 50, 50) # tile text
bg_color = (30, 30, 40) # main background
# menu functions
def start_game_func(*_):
create_tiles()
menu_manager.change_menu("game")
def settings_menu(*_):
menu_manager.change_menu("settings")
def go_back(*_):
menu_manager.change_menu("start")
# tile stuff
def create_tiles():
global tiles
tiles = []
start_x = (WINDOW_SIZE[0] - GRID_WIDTH) // 2
start_y = (WINDOW_SIZE[1] - GRID_HEIGHT) // 2
for c in range(COLS):
for r in range(ROWS):
x = start_x + c * (TILE_SIZE + TILE_SPACING)
y = start_y + r * (TILE_SIZE + TILE_SPACING)
tile = Button(
x, y, TILE_SIZE, TILE_SIZE, str(len(tiles)),
tile_color, tile_hover, tile_text, tile_press, None, 30, (len(tiles), c, r),
rounding=5
)
tiles.append(tile)
def tile_press(tile):
tile_id, x, y = tile.extra_data
print(f"TILE {tile_id} at {x},{y} PRESSED")
# button stuff
width, height = 280, 75
x = WINDOW_SIZE[0] / 2 - width / 2
y = WINDOW_SIZE[1] / 2 - height / 2
start_button = Button(x, y - 100, width, height, "Start Game",
primary_color, hover_color, text_color,
start_game_func, "Baloo2-Bold.ttf", 50, rounding=8)
settings_button = Button(x, y - 100 + height * 2, width, height, "Settings",
primary_color, hover_color, text_color,
settings_menu, "Baloo2-Bold.ttf", 50, rounding=8)
go_back_button = Button(x, y, width, height, "Go back",
primary_color, hover_color, text_color,
go_back, "Baloo2-Bold.ttf", 50, rounding=8)
# menu handlers
# start
def start_menu_events(event):
start_button.handle_event(event)
settings_button.handle_event(event)
def start_menu_draw():
start_button.draw(display)
settings_button.draw(display)
# settings
def settings_menu_events(event):
go_back_button.handle_event(event)
def settings_menu_draw():
text_surface = font.render("No settings yet :(", True, text_color)
text_rect = text_surface.get_rect(center=(WINDOW_SIZE[0] / 2, WINDOW_SIZE[1] / 2 - 100))
display.blit(text_surface, text_rect)
go_back_button.draw(display)
# game
def game_menu_events(event):
for tile in tiles:
tile.handle_event(event)
def game_menu_draw():
for tile in tiles:
tile.draw(display)
# register the menus
menu_manager.register_menu("start", start_menu_events, start_menu_draw)
menu_manager.register_menu("settings", settings_menu_events, settings_menu_draw)
menu_manager.register_menu("game", game_menu_events, game_menu_draw)
menu_manager.change_menu("start")
# main loopy loopy
if __name__ == "__main__":
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False
menu_manager.handle_event(event)
menu_manager.draw()
pygame.display.flip()
clock.tick(TARGET_FPS)

31
gui/menu_manager.py Normal file
View File

@ -0,0 +1,31 @@
import pygame
class MenuManager:
def __init__(self, display, bg_color):
self.display = display
self.bg_color = bg_color
self.current_menu = None
self.event_handlers = {}
self.draw_handlers = {}
# switch menu, and clear the screen
def change_menu(self, name):
if name not in self.draw_handlers:
raise ValueError(f"Menu: '{name} not registered")
self.current_menu = name
self.display.fill(self.bg_color)
# register a menus event and draw functions
def register_menu(self, name, event_handler, draw_handler):
self.event_handlers[name] = event_handler
self.draw_handlers[name] = draw_handler
# pass event to the menu handler
def handle_event(self, event):
if self.current_menu in self.event_handlers:
self.event_handlers[self.current_menu](event)
# draw the current menu
def draw(self):
if self.current_menu in self.draw_handlers:
self.draw_handlers[self.current_menu]()

View File

@ -1,3 +0,0 @@
{
"display_mode": "coloured_background"
}