584 lines
20 KiB
Python
584 lines
20 KiB
Python
"""Connect Four terminal game with AI, using Rich for display."""
|
|
|
|
import os
|
|
import queue
|
|
import random
|
|
import threading
|
|
import time
|
|
from enum import Enum, auto
|
|
from pathlib import Path
|
|
|
|
import readchar
|
|
from dotenv import load_dotenv
|
|
from rich.console import Console, Group
|
|
from rich.live import Live
|
|
from rich.text import Text
|
|
|
|
load_dotenv(Path(__file__).parent / ".env")
|
|
|
|
# --- Configuration from .env ---
|
|
LOOK_AHEAD = int(os.getenv("LOOK_AHEAD", "8"))
|
|
BLUNDER_ENABLED = os.getenv("BLUNDER_ENABLED", "false").lower() == "true"
|
|
BLUNDER_CHANCE = int(os.getenv("BLUNDER_CHANCE", "20"))
|
|
DEMO_RESET_PAUSE = int(os.getenv("DEMO_RESET_PAUSE", "5"))
|
|
IDLE_TIMEOUT = int(os.getenv("IDLE_TIMEOUT", "60"))
|
|
MAX_GAME_LOG = int(os.getenv("MAX_GAME_LOG", "100"))
|
|
GAMES_FILE = Path(__file__).parent / ".games.txt"
|
|
|
|
COLS = 7
|
|
ROWS = 6
|
|
COL_ORDER = [3, 2, 4, 1, 5, 0, 6]
|
|
|
|
# Box-drawing characters for the board frame
|
|
DISC = "\u2b24"
|
|
EMPTY = "\u25cb"
|
|
H_LINE = "\u2500"
|
|
V_LINE = "\u2502"
|
|
TL = "\u250c"
|
|
TR = "\u2510"
|
|
BL = "\u2514"
|
|
BR = "\u2518"
|
|
T_DOWN = "\u252c"
|
|
T_UP = "\u2534"
|
|
T_RIGHT = "\u251c"
|
|
T_LEFT = "\u2524"
|
|
CROSS = "\u253c"
|
|
|
|
console = Console()
|
|
|
|
# Key constants - readchar uses escape sequences
|
|
KEY_LEFT = readchar.key.LEFT if hasattr(readchar.key, "LEFT") else "\x1b[D"
|
|
KEY_RIGHT = readchar.key.RIGHT if hasattr(readchar.key, "RIGHT") else "\x1b[C"
|
|
KEY_UP = readchar.key.UP if hasattr(readchar.key, "UP") else "\x1b[A"
|
|
KEY_DOWN = readchar.key.DOWN if hasattr(readchar.key, "DOWN") else "\x1b[B"
|
|
KEY_ENTER = readchar.key.ENTER if hasattr(readchar.key, "ENTER") else "\r"
|
|
CONFIRM_KEYS = {KEY_ENTER, " ", "\r", "\n"}
|
|
|
|
|
|
class State(Enum):
|
|
MENU = auto()
|
|
PLAYING = auto()
|
|
AI_TURN = auto()
|
|
FINISHED_WIN = auto()
|
|
FINISHED_DRAW = auto()
|
|
DEMO = auto()
|
|
|
|
|
|
def player_name(player: int) -> str:
|
|
return "Yellow" if player == 1 else "Red"
|
|
|
|
|
|
def player_style(player: int) -> str:
|
|
return "bold yellow" if player == 1 else "bold red"
|
|
|
|
|
|
def dim_player_style(player: int) -> str:
|
|
return "dim yellow" if player == 1 else "dim red"
|
|
|
|
|
|
# --- Board ---
|
|
|
|
def make_board() -> list[list[int]]:
|
|
return [[0] * ROWS for _ in range(COLS)]
|
|
|
|
|
|
def get_first_empty_row(board: list[list[int]], col: int) -> int:
|
|
for r in range(ROWS):
|
|
if board[col][r] == 0:
|
|
return r
|
|
return -1
|
|
|
|
|
|
def is_board_full(board: list[list[int]]) -> bool:
|
|
return all(board[c][ROWS - 1] != 0 for c in range(COLS))
|
|
|
|
|
|
def scan_board(board: list[list[int]]) -> tuple[int, list[tuple[int, int]]]:
|
|
"""Returns (winner, winning_positions). winner=0 if no winner."""
|
|
def check(c, r, dc, dr):
|
|
p = board[c][r]
|
|
if p != 0:
|
|
positions = [(c + i * dc, r + i * dr) for i in range(4)]
|
|
if all(board[cc][rr] == p for cc, rr in positions):
|
|
return p, positions
|
|
return 0, []
|
|
|
|
for r in range(ROWS):
|
|
for c in range(COLS - 3):
|
|
w, pos = check(c, r, 1, 0)
|
|
if w:
|
|
return w, pos
|
|
for r in range(ROWS - 3):
|
|
for c in range(COLS):
|
|
w, pos = check(c, r, 0, 1)
|
|
if w:
|
|
return w, pos
|
|
for r in range(ROWS - 3):
|
|
for c in range(COLS - 3):
|
|
w, pos = check(c, r, 1, 1)
|
|
if w:
|
|
return w, pos
|
|
for r in range(3, ROWS):
|
|
for c in range(COLS - 3):
|
|
w, pos = check(c, r, 1, -1)
|
|
if w:
|
|
return w, pos
|
|
return 0, []
|
|
|
|
|
|
# --- Display ---
|
|
|
|
def render_board(
|
|
board: list[list[int]],
|
|
active_col: int = -1,
|
|
current_player: int = 0,
|
|
win_positions: list[tuple[int, int]] | None = None,
|
|
flash_off: bool = False,
|
|
is_draw_flash: bool = False,
|
|
thinking_col: int = -1,
|
|
thinking_bright: bool = False,
|
|
) -> Text:
|
|
cell_w = 4 # width per cell including padding
|
|
|
|
lines = Text()
|
|
|
|
# Cursor row above the board
|
|
cursor_line = Text(" ")
|
|
for c in range(COLS):
|
|
if thinking_col == c:
|
|
style = player_style(current_player) if thinking_bright else dim_player_style(current_player)
|
|
cursor_line.append(f" {DISC} ", style=style)
|
|
elif c == active_col and current_player > 0:
|
|
cursor_line.append(f" {DISC} ", style=player_style(current_player))
|
|
else:
|
|
cursor_line.append(" ")
|
|
lines.append_text(cursor_line)
|
|
lines.append("\n")
|
|
|
|
# Column numbers row
|
|
num_line = Text(" ")
|
|
for c in range(COLS):
|
|
style = "bold white" if c == active_col else "dim"
|
|
num_line.append(f" {c + 1} ", style=style)
|
|
lines.append_text(num_line)
|
|
lines.append("\n")
|
|
|
|
# Top border
|
|
top = Text(" ", style="bold blue")
|
|
top.append(TL, style="bold blue")
|
|
for c in range(COLS):
|
|
top.append(H_LINE * (cell_w - 1), style="bold blue")
|
|
top.append(T_DOWN if c < COLS - 1 else TR, style="bold blue")
|
|
lines.append_text(top)
|
|
lines.append("\n")
|
|
|
|
# Board rows (top row of board = row 5, displayed first)
|
|
for r in range(ROWS - 1, -1, -1):
|
|
row_line = Text(" ", style="bold blue")
|
|
for c in range(COLS):
|
|
row_line.append(V_LINE, style="bold blue")
|
|
val = board[c][r]
|
|
if val == 0:
|
|
row_line.append(f" {EMPTY} ", style="dim blue")
|
|
else:
|
|
is_win = win_positions and (c, r) in win_positions
|
|
if flash_off and is_win:
|
|
row_line.append(" ")
|
|
elif is_draw_flash and flash_off:
|
|
row_line.append(" ")
|
|
elif not is_win and win_positions:
|
|
row_line.append(f" {DISC} ", style=dim_player_style(val))
|
|
else:
|
|
row_line.append(f" {DISC} ", style=player_style(val))
|
|
row_line.append(V_LINE, style="bold blue")
|
|
lines.append_text(row_line)
|
|
lines.append("\n")
|
|
|
|
# Row separator or bottom border
|
|
if r > 0:
|
|
sep = Text(" ", style="bold blue")
|
|
sep.append(T_RIGHT, style="bold blue")
|
|
for c in range(COLS):
|
|
sep.append(H_LINE * (cell_w - 1), style="bold blue")
|
|
sep.append(CROSS if c < COLS - 1 else T_LEFT, style="bold blue")
|
|
lines.append_text(sep)
|
|
lines.append("\n")
|
|
|
|
# Bottom border
|
|
bot = Text(" ", style="bold blue")
|
|
bot.append(BL, style="bold blue")
|
|
for c in range(COLS):
|
|
bot.append(H_LINE * (cell_w - 1), style="bold blue")
|
|
bot.append(T_UP if c < COLS - 1 else BR, style="bold blue")
|
|
lines.append_text(bot)
|
|
lines.append("\n")
|
|
|
|
return lines
|
|
|
|
|
|
def render_menu(menu_mode: int) -> Text:
|
|
items = ["1P Yellow (you start)", "1P Red (AI starts)", "Multiplayer"]
|
|
lines = ["\n [bold blue]Connect Four[/bold blue]\n"]
|
|
for i, item in enumerate(items):
|
|
marker = " \u25b6 " if i == menu_mode else " "
|
|
style = "bold yellow" if i == 0 else "bold red" if i == 1 else "bold blue"
|
|
if i == menu_mode:
|
|
lines.append(f"[{style}]{marker}{item}[/{style}]")
|
|
else:
|
|
lines.append(f"[dim]{marker}{item}[/dim]")
|
|
lines.append("\n [dim]Up/Down to select, Space/Enter to start, Q to quit[/dim]\n")
|
|
return Text.from_markup("\n".join(lines))
|
|
|
|
|
|
# --- Game log ---
|
|
|
|
def load_game_log() -> list[dict]:
|
|
if not GAMES_FILE.exists():
|
|
return []
|
|
games = []
|
|
for line in GAMES_FILE.read_text().splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
parts = line.split(":", 3)
|
|
if len(parts) == 4:
|
|
games.append({
|
|
"type": parts[0],
|
|
"level": parts[1],
|
|
"winner": parts[2],
|
|
"moves": parts[3],
|
|
})
|
|
return games[-MAX_GAME_LOG:]
|
|
|
|
|
|
def save_game_log(games: list[dict]):
|
|
with GAMES_FILE.open("w") as f:
|
|
for g in games:
|
|
f.write(f"{g['type']}:{g['level']}:{g['winner']}:{g['moves']}\n")
|
|
|
|
|
|
def log_game(games: list[dict], game_menu_mode: int, level: int, winner: int, moves: str) -> list[dict]:
|
|
game_type = "Y" if game_menu_mode == 0 else "R" if game_menu_mode == 1 else "2"
|
|
win_char = "Y" if winner == 1 else "R" if winner == 2 else "D"
|
|
entry = {"type": game_type, "level": str(level), "winner": win_char, "moves": moves}
|
|
games.append(entry)
|
|
games = games[-MAX_GAME_LOG:]
|
|
save_game_log(games)
|
|
return games
|
|
|
|
|
|
# --- AI ---
|
|
|
|
def minimax(
|
|
board: list[list[int]], depth: int, alpha: int, beta: int,
|
|
is_max: bool, ai_p: int, hu_p: int,
|
|
) -> int:
|
|
winner, _ = scan_board(board)
|
|
if winner == ai_p:
|
|
return 1000 + depth
|
|
if winner == hu_p:
|
|
return -1000 - depth
|
|
if depth == 0 or is_board_full(board):
|
|
return 0
|
|
|
|
best = -10000 if is_max else 10000
|
|
for c in COL_ORDER:
|
|
r = get_first_empty_row(board, c)
|
|
if r != -1:
|
|
board[c][r] = ai_p if is_max else hu_p
|
|
score = minimax(board, depth - 1, alpha, beta, not is_max, ai_p, hu_p)
|
|
board[c][r] = 0
|
|
if is_max:
|
|
if score > best:
|
|
best = score
|
|
if best > alpha:
|
|
alpha = best
|
|
else:
|
|
if score < best:
|
|
best = score
|
|
if best < beta:
|
|
beta = best
|
|
if beta <= alpha:
|
|
break
|
|
return best
|
|
|
|
|
|
def perform_ai_move(
|
|
board: list[list[int]], ai_p: int, look_ahead: int, is_demo: bool = False, demo_ply: int = 4,
|
|
) -> int:
|
|
hu_p = 2 if ai_p == 1 else 1
|
|
ply = demo_ply if is_demo else look_ahead
|
|
|
|
# Phase 1: instant win / block
|
|
for c in range(COLS):
|
|
r = get_first_empty_row(board, c)
|
|
if r != -1:
|
|
board[c][r] = ai_p
|
|
if scan_board(board)[0] == ai_p:
|
|
board[c][r] = 0
|
|
return c
|
|
board[c][r] = hu_p
|
|
if scan_board(board)[0] == hu_p:
|
|
board[c][r] = 0
|
|
return c
|
|
board[c][r] = 0
|
|
|
|
# Phase 2: blunder
|
|
if not is_demo and BLUNDER_ENABLED and random.randint(0, 99) < BLUNDER_CHANCE:
|
|
valid = [c for c in range(COLS) if get_first_empty_row(board, c) != -1]
|
|
return random.choice(valid)
|
|
|
|
# Phase 3: minimax
|
|
best_score = -30000
|
|
best_col = 3
|
|
for c in COL_ORDER:
|
|
r = get_first_empty_row(board, c)
|
|
if r != -1:
|
|
board[c][r] = ai_p
|
|
score = minimax(board, ply, -30000, 30000, False, ai_p, hu_p)
|
|
board[c][r] = 0
|
|
if score > best_score:
|
|
best_score = score
|
|
best_col = c
|
|
return best_col
|
|
|
|
|
|
def randomize_demo_plies() -> tuple[int, int]:
|
|
strong = random.randint(4, 5)
|
|
weak = random.randint(2, 3)
|
|
if random.randint(0, 1) == 0:
|
|
return strong, weak
|
|
return weak, strong
|
|
|
|
|
|
# --- Input (cross-platform, non-blocking via thread) ---
|
|
|
|
_key_queue: queue.Queue[str] = queue.Queue()
|
|
_input_stop = threading.Event()
|
|
|
|
|
|
def _input_thread():
|
|
"""Background thread that reads keys and puts them on the queue."""
|
|
while not _input_stop.is_set():
|
|
try:
|
|
key = readchar.readkey()
|
|
_key_queue.put(key)
|
|
except Exception:
|
|
break
|
|
|
|
|
|
def read_key() -> str | None:
|
|
"""Non-blocking key read from the queue."""
|
|
try:
|
|
return _key_queue.get_nowait()
|
|
except queue.Empty:
|
|
return None
|
|
|
|
|
|
# --- Main game loop ---
|
|
|
|
def main():
|
|
console.clear()
|
|
|
|
game_state = State.MENU
|
|
board = make_board()
|
|
menu_mode = 0
|
|
current_player = 1
|
|
active_col = 3
|
|
winner_player = 0
|
|
win_positions: list[tuple[int, int]] = []
|
|
current_moves = ""
|
|
game_menu_mode = 0
|
|
game_level = LOOK_AHEAD
|
|
games = load_game_log()
|
|
demo_ply = (4, 4)
|
|
last_activity = time.time()
|
|
demo_reset_timer = 0.0
|
|
flash_toggle = True
|
|
last_flash = 0.0
|
|
|
|
def reset():
|
|
nonlocal board, winner_player, win_positions, current_moves
|
|
board = make_board()
|
|
winner_player = 0
|
|
win_positions = []
|
|
current_moves = ""
|
|
|
|
def check_game_end() -> bool:
|
|
nonlocal winner_player, win_positions, game_state, games, demo_reset_timer, last_activity
|
|
winner_player, win_positions = scan_board(board)
|
|
won = winner_player != 0
|
|
draw = not won and is_board_full(board)
|
|
if not won and not draw:
|
|
return False
|
|
if game_state != State.DEMO:
|
|
games = log_game(games, game_menu_mode, game_level, winner_player if won else 0, current_moves)
|
|
game_state = State.FINISHED_WIN if won else State.FINISHED_DRAW
|
|
demo_reset_timer = time.time()
|
|
last_activity = time.time()
|
|
return True
|
|
|
|
# Start input thread
|
|
input_thread = threading.Thread(target=_input_thread, daemon=True)
|
|
input_thread.start()
|
|
|
|
try:
|
|
with Live(render_menu(menu_mode), console=console, refresh_per_second=10, screen=True) as live:
|
|
while True:
|
|
key = read_key()
|
|
|
|
# Quit
|
|
if key in ("q", "Q"):
|
|
break
|
|
|
|
# --- MENU ---
|
|
if game_state == State.MENU:
|
|
if key in (KEY_UP,):
|
|
menu_mode = (menu_mode - 1) % 3
|
|
last_activity = time.time()
|
|
elif key in (KEY_DOWN,):
|
|
menu_mode = (menu_mode + 1) % 3
|
|
last_activity = time.time()
|
|
elif key in CONFIRM_KEYS:
|
|
reset()
|
|
game_menu_mode = menu_mode
|
|
game_level = LOOK_AHEAD
|
|
current_player = 1
|
|
active_col = 3
|
|
if menu_mode == 1:
|
|
game_state = State.AI_TURN
|
|
else:
|
|
game_state = State.PLAYING
|
|
last_activity = time.time()
|
|
|
|
if game_state == State.MENU:
|
|
live.update(render_menu(menu_mode))
|
|
time.sleep(0.05)
|
|
continue
|
|
|
|
# --- Interrupt: return to menu from finished/demo ---
|
|
if game_state in (State.FINISHED_WIN, State.FINISHED_DRAW, State.DEMO) and key is not None:
|
|
reset()
|
|
game_state = State.MENU
|
|
menu_mode = 0
|
|
last_activity = time.time()
|
|
live.update(render_menu(menu_mode))
|
|
time.sleep(0.2)
|
|
continue
|
|
|
|
# --- Idle timeout: enter demo ---
|
|
if game_state not in (State.DEMO, State.FINISHED_WIN, State.FINISHED_DRAW):
|
|
if time.time() - last_activity > IDLE_TIMEOUT:
|
|
reset()
|
|
demo_ply = randomize_demo_plies()
|
|
game_state = State.DEMO
|
|
current_player = 1
|
|
|
|
# --- PLAYING ---
|
|
if game_state == State.PLAYING:
|
|
if key in (KEY_LEFT,):
|
|
active_col = max(0, active_col - 1)
|
|
last_activity = time.time()
|
|
elif key in (KEY_RIGHT,):
|
|
active_col = min(COLS - 1, active_col + 1)
|
|
last_activity = time.time()
|
|
elif key in ("1", "2", "3", "4", "5", "6", "7"):
|
|
col = int(key) - 1
|
|
r = get_first_empty_row(board, col)
|
|
if r != -1:
|
|
active_col = col
|
|
current_moves += str(col)
|
|
board[col][r] = current_player
|
|
if not check_game_end():
|
|
if menu_mode < 2:
|
|
game_state = State.AI_TURN
|
|
else:
|
|
current_player = 2 if current_player == 1 else 1
|
|
last_activity = time.time()
|
|
elif key in CONFIRM_KEYS:
|
|
r = get_first_empty_row(board, active_col)
|
|
if r != -1:
|
|
current_moves += str(active_col)
|
|
board[active_col][r] = current_player
|
|
if not check_game_end():
|
|
if menu_mode < 2:
|
|
game_state = State.AI_TURN
|
|
else:
|
|
current_player = 2 if current_player == 1 else 1
|
|
last_activity = time.time()
|
|
|
|
live.update(render_board(board, active_col, current_player))
|
|
|
|
# --- AI_TURN ---
|
|
elif game_state == State.AI_TURN:
|
|
ai_p = 2 if menu_mode == 0 else 1
|
|
live.update(render_board(board, -1, ai_p, thinking_col=active_col, thinking_bright=True))
|
|
|
|
best_col = perform_ai_move(board, ai_p, LOOK_AHEAD)
|
|
r = get_first_empty_row(board, best_col)
|
|
if r != -1:
|
|
current_moves += str(best_col)
|
|
board[best_col][r] = ai_p
|
|
active_col = best_col
|
|
if not check_game_end():
|
|
game_state = State.PLAYING
|
|
current_player = 2 if ai_p == 1 else 1
|
|
last_activity = time.time()
|
|
|
|
live.update(render_board(board, active_col, current_player, win_positions if winner_player else None))
|
|
|
|
# --- DEMO ---
|
|
elif game_state == State.DEMO:
|
|
ply = demo_ply[current_player - 1]
|
|
best_col = perform_ai_move(board, current_player, LOOK_AHEAD, is_demo=True, demo_ply=ply)
|
|
r = get_first_empty_row(board, best_col)
|
|
if r != -1:
|
|
board[best_col][r] = current_player
|
|
if not check_game_end():
|
|
current_player = 2 if current_player == 1 else 1
|
|
|
|
live.update(render_board(board, -1, 0))
|
|
time.sleep(0.4)
|
|
|
|
# --- FINISHED ---
|
|
elif game_state in (State.FINISHED_WIN, State.FINISHED_DRAW):
|
|
now = time.time()
|
|
if now - last_flash > 0.4:
|
|
last_flash = now
|
|
flash_toggle = not flash_toggle
|
|
|
|
if game_state == State.FINISHED_WIN:
|
|
style = player_style(winner_player)
|
|
status = Text.from_markup(
|
|
f"\n [{style}]{player_name(winner_player)} wins![/{style}] [dim]Press any key for menu[/dim]\n"
|
|
)
|
|
tbl = render_board(board, -1, 0, win_positions, flash_off=flash_toggle)
|
|
else:
|
|
status = Text.from_markup(
|
|
"\n [bold]Draw![/bold] [dim]Press any key for menu[/dim]\n"
|
|
)
|
|
tbl = render_board(board, -1, 0, is_draw_flash=True, flash_off=flash_toggle)
|
|
|
|
live.update(Group(tbl, status))
|
|
|
|
# Auto-restart to demo after pause
|
|
if time.time() - demo_reset_timer > DEMO_RESET_PAUSE:
|
|
reset()
|
|
demo_ply = randomize_demo_plies()
|
|
game_state = State.DEMO
|
|
current_player = 1
|
|
last_activity = time.time()
|
|
|
|
time.sleep(0.05)
|
|
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
_input_stop.set()
|
|
console.clear()
|
|
console.print("[bold]Thanks for playing![/bold]")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|