From 89afe981cd865df2ce835e4fb104f501d7c04119 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 14 Nov 2025 16:15:09 +0100 Subject: [PATCH] New app: Connect4 game --- .../META-INF/MANIFEST.JSON | 23 + .../assets/connect4.py | 468 ++++++++++++++++++ .../generate_icon.py | 34 ++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 440 bytes 4 files changed, 525 insertions(+) create mode 100644 internal_filesystem/apps/com.micropythonos.connect4/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py create mode 100644 internal_filesystem/apps/com.micropythonos.connect4/generate_icon.py create mode 100644 internal_filesystem/apps/com.micropythonos.connect4/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/com.micropythonos.connect4/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.connect4/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..1da4896b --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.connect4/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "Connect 4", +"publisher": "MicroPythonOS", +"short_description": "Classic Connect 4 game", +"long_description": "Play Connect 4 against the computer with three difficulty levels: Easy, Medium, and Hard. Drop colored discs and try to connect four in a row!", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.connect4/icons/com.micropythonos.connect4_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.connect4/mpks/com.micropythonos.connect4_0.0.1.mpk", +"fullname": "com.micropythonos.connect4", +"version": "0.0.1", +"category": "games", +"activities": [ + { + "entrypoint": "assets/connect4.py", + "classname": "Connect4", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} diff --git a/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py b/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py new file mode 100644 index 00000000..7bd7bc7d --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py @@ -0,0 +1,468 @@ +import time +import random + +from mpos.apps import Activity +import mpos.ui + +try: + import lvgl as lv +except ImportError: + pass # lv is already available as a global in MicroPython OS + + +class Connect4(Activity): + # Board dimensions + COLS = 7 + ROWS = 6 + + # Screen layout + SCREEN_WIDTH = 320 + SCREEN_HEIGHT = 240 + BOARD_TOP = 50 + CELL_SIZE = 35 + PIECE_RADIUS = 14 + + # Colors + COLOR_EMPTY = 0x2C3E50 + COLOR_PLAYER = 0xE74C3C # Red + COLOR_COMPUTER = 0xF1C40F # Yellow + COLOR_BOARD = 0x3498DB # Blue + COLOR_HIGHLIGHT = 0x2ECC71 # Green + COLOR_WIN = 0x9B59B6 # Purple + + # Game state + EMPTY = 0 + PLAYER = 1 + COMPUTER = 2 + + # Difficulty levels + DIFFICULTY_EASY = 0 + DIFFICULTY_MEDIUM = 1 + DIFFICULTY_HARD = 2 + + def __init__(self): + super().__init__() + self.board = [[self.EMPTY for _ in range(self.COLS)] for _ in range(self.ROWS)] + self.difficulty = self.DIFFICULTY_EASY + self.game_over = False + self.winner = None + self.winning_positions = [] + self.current_player = self.PLAYER + self.animating = False + + # UI elements + self.screen = None + self.pieces = [] # 2D array of LVGL objects + self.column_buttons = [] + self.status_label = None + self.difficulty_label = None + self.last_time = 0 + + def onCreate(self): + self.screen = lv.obj() + self.screen.set_style_bg_color(lv.color_hex(0x34495E), 0) + self.screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # Title + title = lv.label(self.screen) + title.set_text("Connect 4") + title.set_style_text_font(lv.font_montserrat_20, 0) + title.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + title.set_pos(10, 5) + + # Difficulty selector + difficulty_cont = lv.obj(self.screen) + difficulty_cont.set_size(150, 30) + difficulty_cont.set_pos(165, 5) + difficulty_cont.set_style_bg_color(lv.color_hex(0x2C3E50), 0) + difficulty_cont.set_style_border_width(1, 0) + difficulty_cont.set_style_border_color(lv.color_hex(0xFFFFFF), 0) + difficulty_cont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + difficulty_cont.add_flag(lv.obj.FLAG.CLICKABLE) + difficulty_cont.add_event_cb(self.cycle_difficulty, lv.EVENT.CLICKED, None) + + self.difficulty_label = lv.label(difficulty_cont) + self.difficulty_label.set_text("Difficulty: Easy") + self.difficulty_label.set_style_text_font(lv.font_montserrat_12, 0) + self.difficulty_label.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + self.difficulty_label.center() + + # Status label + self.status_label = lv.label(self.screen) + self.status_label.set_text("Your turn!") + self.status_label.set_style_text_font(lv.font_montserrat_14, 0) + self.status_label.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + self.status_label.set_pos(10, 32) + + # Create board background + board_bg = lv.obj(self.screen) + board_bg.set_size(self.COLS * self.CELL_SIZE + 10, self.ROWS * self.CELL_SIZE + 10) + board_bg.set_pos( + (self.SCREEN_WIDTH - self.COLS * self.CELL_SIZE) // 2 - 5, + self.BOARD_TOP - 5 + ) + board_bg.set_style_bg_color(lv.color_hex(self.COLOR_BOARD), 0) + board_bg.set_style_radius(8, 0) + board_bg.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + + # Create pieces (visual representation) + board_x = (self.SCREEN_WIDTH - self.COLS * self.CELL_SIZE) // 2 + for row in range(self.ROWS): + piece_row = [] + for col in range(self.COLS): + piece = lv.obj(self.screen) + piece.set_size(self.PIECE_RADIUS * 2, self.PIECE_RADIUS * 2) + x = board_x + col * self.CELL_SIZE + (self.CELL_SIZE - self.PIECE_RADIUS * 2) // 2 + y = self.BOARD_TOP + row * self.CELL_SIZE + (self.CELL_SIZE - self.PIECE_RADIUS * 2) // 2 + piece.set_pos(x, y) + piece.set_style_radius(lv.RADIUS_CIRCLE, 0) + piece.set_style_bg_color(lv.color_hex(self.COLOR_EMPTY), 0) + piece.set_style_border_width(1, 0) + piece.set_style_border_color(lv.color_hex(0x1C2833), 0) + piece.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + piece_row.append(piece) + self.pieces.append(piece_row) + + # Create column buttons (invisible clickable areas) + for col in range(self.COLS): + btn = lv.obj(self.screen) + btn.set_size(self.CELL_SIZE, self.ROWS * self.CELL_SIZE) + x = board_x + col * self.CELL_SIZE + btn.set_pos(x, self.BOARD_TOP) + btn.set_style_bg_opa(0, 0) # Transparent + btn.set_style_border_width(0, 0) + btn.add_flag(lv.obj.FLAG.CLICKABLE) + btn.add_event_cb(lambda e, c=col: self.on_column_click(c), lv.EVENT.CLICKED, None) + self.column_buttons.append(btn) + + # New Game button + new_game_btn = lv.button(self.screen) + new_game_btn.set_size(100, 30) + new_game_btn.align(lv.ALIGN.BOTTOM_MID, 0, -5) + new_game_btn.add_event_cb(lambda e: self.new_game(), lv.EVENT.CLICKED, None) + new_game_label = lv.label(new_game_btn) + new_game_label.set_text("New Game") + new_game_label.center() + + self.setContentView(self.screen) + + def onResume(self, screen): + self.last_time = time.ticks_ms() + + def cycle_difficulty(self, event): + if self.animating: + return + self.difficulty = (self.difficulty + 1) % 3 + difficulty_names = ["Easy", "Medium", "Hard"] + self.difficulty_label.set_text(f"Difficulty: {difficulty_names[self.difficulty]}") + self.difficulty_label.center() + + def on_column_click(self, col): + if self.game_over or self.animating or self.current_player != self.PLAYER: + return + + if self.drop_piece(col, self.PLAYER): + self.animate_drop(col) + + def drop_piece(self, col, player): + """Try to drop a piece in the given column. Returns True if successful.""" + # Find the lowest empty row in this column + for row in range(self.ROWS - 1, -1, -1): + if self.board[row][col] == self.EMPTY: + self.board[row][col] = player + return True + return False + + def animate_drop(self, col): + """Animate the piece dropping and then check for win/computer move""" + self.animating = True + + # Find which row the piece landed in + row = -1 + player = self.EMPTY + for r in range(self.ROWS): + if self.board[r][col] != self.EMPTY: + row = r + player = self.board[r][col] + break + + if row == -1: + self.animating = False + return + + # Update the visual + color = self.COLOR_PLAYER if player == self.PLAYER else self.COLOR_COMPUTER + self.pieces[row][col].set_style_bg_color(lv.color_hex(color), 0) + + # Check for win or tie + if self.check_win(row, col): + self.game_over = True + self.winner = player + self.highlight_winning_pieces() + winner_text = "You win!" if player == self.PLAYER else "Computer wins!" + self.status_label.set_text(winner_text) + self.animating = False + return + + if self.is_board_full(): + self.game_over = True + self.status_label.set_text("It's a tie!") + self.animating = False + return + + # Switch player + self.current_player = self.COMPUTER if player == self.PLAYER else self.PLAYER + + if self.current_player == self.COMPUTER: + self.status_label.set_text("Computer thinking...") + # Delay computer move slightly for better UX + lv.timer_create(lambda t: self.computer_move(), 500, None).set_repeat_count(1) + else: + self.status_label.set_text("Your turn!") + self.animating = False + + def computer_move(self): + """Make a computer move based on difficulty""" + if self.game_over: + self.animating = False + return + + if self.difficulty == self.DIFFICULTY_EASY: + col = self.get_random_move() + elif self.difficulty == self.DIFFICULTY_MEDIUM: + col = self.get_medium_move() + else: # HARD + col = self.get_hard_move() + + if col is not None and self.drop_piece(col, self.COMPUTER): + self.animate_drop(col) + else: + self.animating = False + + def get_random_move(self): + """Easy: Random valid column""" + valid_cols = [c for c in range(self.COLS) if self.board[0][c] == self.EMPTY] + return random.choice(valid_cols) if valid_cols else None + + def get_medium_move(self): + """Medium: Block player wins, try to win, otherwise random""" + # First, try to win + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.COMPUTER + if self.check_win(row, col): + self.board[row][col] = self.EMPTY + return col + self.board[row][col] = self.EMPTY + + # Second, block player from winning + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.PLAYER + if self.check_win(row, col): + self.board[row][col] = self.EMPTY + return col + self.board[row][col] = self.EMPTY + + # Otherwise, random + return self.get_random_move() + + def get_hard_move(self): + """Hard: Minimax algorithm""" + best_score = -float('inf') + best_col = None + + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.COMPUTER + score = self.minimax(3, False, -float('inf'), float('inf')) + self.board[row][col] = self.EMPTY + + if score > best_score: + best_score = score + best_col = col + + return best_col if best_col is not None else self.get_random_move() + + def minimax(self, depth, is_maximizing, alpha, beta): + """Minimax with alpha-beta pruning""" + # Check terminal states + for row in range(self.ROWS): + for col in range(self.COLS): + if self.board[row][col] != self.EMPTY: + if self.check_win(row, col): + if self.board[row][col] == self.COMPUTER: + return 1000 + else: + return -1000 + + if self.is_board_full(): + return 0 + + if depth == 0: + return self.evaluate_board() + + if is_maximizing: + max_score = -float('inf') + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.COMPUTER + score = self.minimax(depth - 1, False, alpha, beta) + self.board[row][col] = self.EMPTY + max_score = max(max_score, score) + alpha = max(alpha, score) + if beta <= alpha: + break + return max_score + else: + min_score = float('inf') + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.PLAYER + score = self.minimax(depth - 1, True, alpha, beta) + self.board[row][col] = self.EMPTY + min_score = min(min_score, score) + beta = min(beta, score) + if beta <= alpha: + break + return min_score + + def evaluate_board(self): + """Heuristic evaluation of board position""" + score = 0 + + # Evaluate all possible windows of 4 + for row in range(self.ROWS): + for col in range(self.COLS): + if col <= self.COLS - 4: + window = [self.board[row][col + i] for i in range(4)] + score += self.evaluate_window(window) + + if row <= self.ROWS - 4: + window = [self.board[row + i][col] for i in range(4)] + score += self.evaluate_window(window) + + if row <= self.ROWS - 4 and col <= self.COLS - 4: + window = [self.board[row + i][col + i] for i in range(4)] + score += self.evaluate_window(window) + + if row >= 3 and col <= self.COLS - 4: + window = [self.board[row - i][col + i] for i in range(4)] + score += self.evaluate_window(window) + + return score + + def evaluate_window(self, window): + """Evaluate a window of 4 positions""" + score = 0 + computer_count = window.count(self.COMPUTER) + player_count = window.count(self.PLAYER) + empty_count = window.count(self.EMPTY) + + if computer_count == 3 and empty_count == 1: + score += 5 + elif computer_count == 2 and empty_count == 2: + score += 2 + + if player_count == 3 and empty_count == 1: + score -= 4 + + return score + + def is_valid_move(self, col): + """Check if a column has space""" + return self.board[0][col] == self.EMPTY + + def get_next_row(self, col): + """Get the row where a piece would land in this column""" + for row in range(self.ROWS - 1, -1, -1): + if self.board[row][col] == self.EMPTY: + return row + return -1 + + def check_win(self, row, col): + """Check if the piece at (row, col) creates a winning connection""" + player = self.board[row][col] + if player == self.EMPTY: + return False + + # Check horizontal + positions = self.check_direction(row, col, 0, 1) + if len(positions) >= 4: + self.winning_positions = positions + return True + + # Check vertical + positions = self.check_direction(row, col, 1, 0) + if len(positions) >= 4: + self.winning_positions = positions + return True + + # Check diagonal (down-right) + positions = self.check_direction(row, col, 1, 1) + if len(positions) >= 4: + self.winning_positions = positions + return True + + # Check diagonal (down-left) + positions = self.check_direction(row, col, 1, -1) + if len(positions) >= 4: + self.winning_positions = positions + return True + + return False + + def check_direction(self, row, col, dr, dc): + """Count consecutive pieces in a direction (both ways)""" + player = self.board[row][col] + positions = [(row, col)] + + # Check positive direction + r, c = row + dr, col + dc + while 0 <= r < self.ROWS and 0 <= c < self.COLS and self.board[r][c] == player: + positions.append((r, c)) + r += dr + c += dc + + # Check negative direction + r, c = row - dr, col - dc + while 0 <= r < self.ROWS and 0 <= c < self.COLS and self.board[r][c] == player: + positions.append((r, c)) + r -= dr + c -= dc + + return positions + + def highlight_winning_pieces(self): + """Highlight the winning pieces""" + for row, col in self.winning_positions: + self.pieces[row][col].set_style_bg_color(lv.color_hex(self.COLOR_WIN), 0) + self.pieces[row][col].set_style_border_width(3, 0) + self.pieces[row][col].set_style_border_color(lv.color_hex(0xFFFFFF), 0) + + def is_board_full(self): + """Check if the board is full""" + return all(self.board[0][col] != self.EMPTY for col in range(self.COLS)) + + def new_game(self): + """Reset the game""" + self.board = [[self.EMPTY for _ in range(self.COLS)] for _ in range(self.ROWS)] + self.game_over = False + self.winner = None + self.winning_positions = [] + self.current_player = self.PLAYER + self.animating = False + self.status_label.set_text("Your turn!") + + # Reset visual pieces + for row in range(self.ROWS): + for col in range(self.COLS): + self.pieces[row][col].set_style_bg_color(lv.color_hex(self.COLOR_EMPTY), 0) + self.pieces[row][col].set_style_border_width(1, 0) + self.pieces[row][col].set_style_border_color(lv.color_hex(0x1C2833), 0) diff --git a/internal_filesystem/apps/com.micropythonos.connect4/generate_icon.py b/internal_filesystem/apps/com.micropythonos.connect4/generate_icon.py new file mode 100644 index 00000000..d4e55993 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.connect4/generate_icon.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from PIL import Image, ImageDraw + +# Create 64x64 icon +img = Image.new('RGB', (64, 64), color=(52, 73, 94)) +draw = ImageDraw.Draw(img) + +# Draw blue board +draw.rectangle([4, 4, 60, 60], fill=(52, 152, 219)) + +# Draw grid of circles with a pattern +colors = [(231, 76, 60), (241, 196, 15), (44, 62, 80)] # Red, Yellow, Empty + +pattern = [ + [2, 2, 2, 2, 2, 2, 2], + [2, 2, 2, 2, 2, 2, 2], + [2, 2, 0, 0, 2, 2, 2], + [2, 0, 0, 1, 2, 2, 2], + [0, 1, 0, 1, 0, 2, 2], + [0, 1, 1, 0, 0, 1, 2], +] + +cell_size = 8 +start_x = 8 +start_y = 8 + +for row in range(6): + for col in range(7): + x = start_x + col * cell_size + y = start_y + row * cell_size + draw.ellipse([x, y, x + 6, y + 6], fill=colors[pattern[row][col]]) + +img.save('res/mipmap-mdpi/icon_64x64.png') +print("Icon created: res/mipmap-mdpi/icon_64x64.png") diff --git a/internal_filesystem/apps/com.micropythonos.connect4/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.connect4/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..ee77edb2366c16dc3cea99af58ea43e917294194 GIT binary patch literal 440 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4kiW$23787oeT_&^`0({Ar*7pPPgr1aujHd zHjEIk>3n!6LHXd(I}RTN+T7aYl8iqe_5IE!T_xmbVDe()%FG$_&4Zo8O}`3dpDtQ? z{KEU$^F&t9y}Ty#6>Hlmo+a1Te7gQ@Mh0(;z54%^|2StfUYTWD|96i}&Bu+lr(c~H z-#1(&xdulT>iT=K%A8eW&>>Ln~+pLeSi%A>;7bE=T@qrbWXu+q*NW4EUy;y3P=O z&waUBNl+2P+3Ht~*N!nhkFsodW_8_$pZ!A2^I6XyGR%4%^Z6KK?kZETusT@SoKIlk z&c0{0|9>Brka752cj0g2T;_~>#&_gEd$`Hue$L`G4X?@2Y=mJ>Wnrd2Ut moxbz?#W0nx{tkN^#X}_81?8SG-(+B5VDNPHb6Mw<&;$U5LB{_8 literal 0 HcmV?d00001