diff --git a/__pycache__/tetris.cpython-311.pyc b/__pycache__/tetris.cpython-311.pyc new file mode 100644 index 0000000..186aa13 Binary files /dev/null and b/__pycache__/tetris.cpython-311.pyc differ diff --git a/play_game.py b/play_game.py new file mode 100644 index 0000000..001a5d3 --- /dev/null +++ b/play_game.py @@ -0,0 +1,299 @@ +# Tetris game adapted for retro module (graphics-based Pythonista) +# Import libraries for random piece selection and timing +import random +import time + +# Import retro module for graphics (Pythonista only) +try: + from retro.game import Game as RetroGame +except ImportError: + RetroGame = None + +# Board dimensions: 10 columns wide, 20 rows tall (standard Tetris size) +W, H = 10, 20 + +# Graphics settings: cell size in pixels for drawing +CELL_W = 30 # pixels wide per cell +CELL_H = 30 # pixels tall per cell +BORDER = 2 # border line width + +# 2D game board: 20 rows (height) × 10 columns (width) +# Each cell is either None (empty) or a color value (filled with a block) +board = [[None] * W for _ in range(H)] + +# Define all 7 Tetris piece shapes (I, O, T, S, Z, J, L) +# Each shape is stored as a list of (x, y) offsets from the piece's center +BLOCKS = [[] for _ in range(7)] +# Parse ASCII template: each 'o' represents a block, positions calculated from the string +for i, line in enumerate(''' + oo o o oo oo o +oooo oo ooo ooo oo oo ooo'''.split('\n')): + for j, char in enumerate(line): + if char == 'o': + # Store block position relative to piece center + BLOCKS[j // 5].append((j%5 - 1, -i + 2)) + +# Function to check if a piece can be placed at a given position +# Returns True if the position is valid (no collisions), False otherwise +def can_place_block_clipped(block, x0, y0): + # Check each cell of the piece + for dx, dy in block: + # Calculate actual position + x, y = x0 + dx, y0 + dy + # Check if position is out of bounds or collides with existing blocks + if not (0 <= x < W and 0 <= y) or (y < H and board[y][x]): + return False + return True + +# Function to lock a piece permanently onto the board +# Marks each cell of the piece as filled +def place_block(block, x0, y0): + # Assume placement is complete initially + complete = True + # Place each block cell on the board + for dx, dy in block: + x, y = x0 + dx, y0 + dy + # Only place if within board bounds (x and y must be valid) + if 0 <= x < W and 0 <= y < H: + board[y][x] = 'blue' # Mark cell as filled with color 'blue' + else: + # If any part extends outside, placement is incomplete (game over) + complete = False + return complete + +# Function to find rows that are completely filled (ready to be cleared) +# Yields (returns) each full row one at a time +def find_completed_rows(): + for row in board: + # Check if all cells in the row have a color (no None values) + if all(row): + yield row + +# Game state variables +# score: player's current score +score = 0 +# t: game tick counter (increments each game loop iteration) +t = 0 +# falling_period: how many ticks before a piece falls one row (decreases with level) +falling_period = 0 +# state: current game state ('normal' = playing, 'game_over' = lost) +state = 'normal' +# block: currently falling piece (list of offsets), next_block: preview of next piece +block = next_block = None +# x, y: current position of the falling piece (x=column, y=row) +x, y = 0, 0 +# falling_generator: iterator that controls piece falling animation +falling_generator = None +# deleting_rows_generator: iterator that controls line-clear animation +deleting_rows_generator = None + +# Function to move the current falling piece left, right, or down +# dx: horizontal direction (-1=left, +1=right), dy: vertical direction (-1=down) +def move_block(dx, dy): + global x, y + # Calculate new position + new_x, new_y = x + dx, y + dy + # Check if new position is valid (no collisions) + possible = can_place_block_clipped(block, new_x, new_y) + # If valid, update piece position + if possible: + x, y = new_x, new_y + return possible + +# Function to rotate the current falling piece 90 degrees clockwise +# Rotation formula: (x, y) becomes (-y, x) +def rotate_block(): + global block + # Calculate rotated positions + new_block = [(-y, x) for x, y in block] + # Check if rotated position is valid (no collisions) + possible = can_place_block_clipped(new_block, x, y) + # If valid, update piece with rotated version + if possible: + block = new_block + return possible + +# Function to spawn the next piece and start it falling +# Also calculates falling speed based on level (score) +def reset_block(): + global falling_period, block, next_block, x, y, state + # Calculate falling period (how many ticks before piece falls): decreases with level + # Higher t (more time) = higher level = faster falling + falling_period = [10, 9, 8, 7, 6, 5, 4][min(t // 100, 6)] + # Swap pieces: current next_block becomes new block, pick random next + new_block = None + while not new_block: + new_block, next_block = next_block, BLOCKS[random.randrange(7)] + # Spawn at center-top of board + x, y = W // 2, H - 1 + # Check for game over: if new piece collides immediately, game is over + if not can_place_block_clipped(new_block, x, y): + state = 'game_over' + return + # Set active piece and start falling animation + block = new_block + begin_falling(falling_period) + +# Function to create a falling animation generator for a piece +# The piece falls one row every 'period' ticks until it hits bottom +def begin_falling(period): + global falling_generator + def fall(): + global falling_generator + # Main falling loop + while True: + # Wait 'period' ticks before falling next row + for t in range(period): + yield # Pause here and resume on next tick + # Try to move piece down one row + if not move_block(0, -1): + # Piece can't move down, so it has landed + break + # When piece lands, clear the falling generator and lock piece + falling_generator = None + place_block_and_begin_deleting() + yield + # Create and start the generator + falling_generator = fall() + +# Function to lock the current falling piece onto the board permanently +# Then start checking for completed rows to clear +def place_block_and_begin_deleting(): + global block, state + # Save the current piece before clearing it + old_block = block + block = None # Clear active piece + # Place the piece on the board + if not place_block(old_block, x, y): + # If placement failed (piece extended above board), game over + state = 'game_over' + return + # Start animation for clearing completed rows + begin_deleting_rows() + +# Function to animate and process line clears +# Completed rows flash and then disappear, with remaining blocks dropping down +def begin_deleting_rows(): + global deleting_rows_generator + # Check if any rows are completed + if any(find_completed_rows()): + # Animation duration based on falling period (faster at higher levels) + duration = falling_period + def delete(): + global deleting_rows_generator, score + # Animate the row clearing (flash animation) + for t in range(duration): + if t in (0, duration // 2): + # Change color of completed rows (flash effect) + pass + yield # Pause to show animation + # After animation, remove completed rows + incomplete_rows = [row for row in board if not all(row)] + n_deleted = H - len(incomplete_rows) + # Shift remaining rows down and fill with empty rows + board[:] = incomplete_rows + [[None] * W for _ in range(n_deleted)] + # Award points: (number of rows cleared)^2 × 100 + score += (n_deleted**2) * 100 + # Spawn next piece + reset_block() + deleting_rows_generator = None + yield + # Create and start the generator + deleting_rows_generator = delete() + else: + # No completed rows, just spawn next piece immediately + reset_block() + +# Function called once per game tick (100ms) +# Advances animations (falling piece, line clears) and increments the time counter +def update_stage(): + global t + # Advance the falling animation generator (piece drops) + if block and falling_generator: + next(falling_generator) + # Advance the line-clear animation generator + if deleting_rows_generator: + next(deleting_rows_generator) + # Increment tick counter (used for level calculation) + t += 1 + +# Input handler: move piece left +def on_key_left(): + if block: + move_block(-1, 0) + +# Input handler: move piece right +def on_key_right(): + if block: + move_block(1, 0) + +# Input handler: soft drop (or lock piece if already at bottom) +def on_key_down(): + if block: + if not move_block(0, -1): + place_block_and_begin_deleting() + +# Input handler: hard drop (instant fall to bottom) +def on_key_space(): + if block: + begin_falling(period=0) # period=0 means fall instantly + +# Input handler: rotate piece 90 degrees +def on_key_up(): + if block: + rotate_block() + +# Retro Game class for rendering and game loop +class TetrisGame: + def __init__(self): + # Game timing + self.seconds = 0 + self.tick_interval = 0.1 # 100ms per tick + self.last_update = time.time() + + # Initialize game + reset_block() + + def draw_board(self): + # Drawing would happen here for retro module + # For now, this is a placeholder for graphics rendering + pass + + def draw_piece(self): + # Draw the falling piece + pass + + def update(self, dt): + # Update timing + self.seconds += dt + if self.seconds > self.tick_interval: + self.seconds = 0 + update_stage() + + def on_key(self, key): + # Handle keyboard input + if key == 'left' or key == 'a': + on_key_left() + elif key == 'right' or key == 'd': + on_key_right() + elif key == 'down' or key == 's': + on_key_down() + elif key == 'up' or key == 'w': + on_key_up() + elif key == 'space': + on_key_space() + elif key == 'q': + return False # Quit + return True + +# Create and run the game +if __name__ == '__main__': + if RetroGame: + # Use retro module if available + try: + game = TetrisGame() + print('Game initialized. Use arrow keys or WASD to play.') + except Exception as e: + print(f'Error initializing retro game: {e}') + else: + print('Retro module not available. This game requires Pythonista.') diff --git a/play_game2.py b/play_game2.py new file mode 100644 index 0000000..da7ff74 --- /dev/null +++ b/play_game2.py @@ -0,0 +1,452 @@ +# Import libraries for random piece selection, terminal graphics, and timing +import random +import curses +import time + +# Board dimensions: 10 columns wide, 20 rows tall (standard Tetris size) +W, H = 10, 20 + +# Terminal layout: Scale up cells for bigger blocks +# CELL_W: number of characters wide per block (4 = wider blocks) +# CELL_H: number of rows tall per block (2 = taller blocks) +CELL_W = 4 +CELL_H = 2 + +# Function to calculate centered positioning in the terminal +# This reads the terminal size and returns padding values to center the game board +def get_centered_offsets(stdscr): + """Calculate PAD_X and PAD_Y to center the game board in the terminal.""" + # Get terminal dimensions (max_y = height, max_x = width) + max_y, max_x = stdscr.getmaxyx() + # Calculate board dimensions in characters: width and height with borders + board_width = W * CELL_W + 2 + board_height = H * CELL_H + 2 + # Total width includes board + side panel (score, next piece preview) + total_width = board_width + 6 + 20 + # Center horizontally and vertically: divide remaining space by 2 + pad_x = max(1, (max_x - total_width) // 2) + pad_y = max(1, (max_y - board_height) // 2) + return pad_x, pad_y + +# Default padding values (will be overridden by get_centered_offsets in main_curses) +# PAD_X: left padding (columns from terminal edge to board edge) +# PAD_Y: top padding (rows from terminal top to board edge) +# SIDE_X: column position of the side panel (score, next block) +PAD_X = 2 +PAD_Y = 1 +SIDE_X = PAD_X + W * CELL_W + 6 + +# 2D game board: 20 rows (height) × 10 columns (width) +# Each cell is either None (empty) or a color string (filled with a block) +board = [[None] * W for _ in range(H)] + +# Function to draw the game board on screen +def draw_board_curses(win): + # Draw the left and right borders (| characters) + for y in range(H + 2): + win.addstr(PAD_Y + y, PAD_X - 1, '|') + win.addstr(PAD_Y + y, PAD_X + W * 2, '|') + # Draw border that spans exactly from the top corner to the bottom corner + top_row = PAD_Y - 1 + bottom_row = PAD_Y + H * CELL_H + left_col = PAD_X - 1 + right_col = PAD_X + W * CELL_W + # Draw vertical borders from top_row to bottom_row (inclusive) + for row in range(top_row, bottom_row + 1): + win.addstr(row, left_col, '|') + win.addstr(row, right_col, '|') + # Draw top and bottom border lines + top = '+' + '-' * (W * CELL_W) + '+' + win.addstr(top_row, left_col, top) + win.addstr(bottom_row, left_col, top) + # Draw each cell on the board (filled cells are shown as ██, empty as spaces) + for i, row in enumerate(board[::-1]): # board[::-1] reverses rows so row 0 (bottom) is at bottom + for h in range(CELL_H): # CELL_H is the height in rows each block takes + screen_row = PAD_Y + 1 + i * CELL_H + h + for j, color in enumerate(row): + # Empty cell: spaces; filled cell: solid blocks (██) + ch = ' ' * CELL_W + attr = curses.color_pair(0) + if color: + ch = '█' * CELL_W + attr = curses.color_pair(2) + win.addstr(screen_row, PAD_X + j * CELL_W, ch, attr) + + +# Function to find rows that are completely filled (ready to be cleared) +# Yields (returns) each full row one at a time +def find_completed_rows(): + for row in board: + # Check if all cells in the row have a color (no None values) + if all(row): + yield row + +# Define all 7 Tetris piece shapes (I, O, T, S, Z, J, L) +# Each shape is stored as a list of (x, y) offsets from the piece's center +BLOCKS = [[] for _ in range(7)] +# Parse ASCII template: each 'o' represents a block, positions calculated from the string +for i, line in enumerate(''' + oo o o oo oo o +oooo oo ooo ooo oo oo ooo'''.split('\n')): + for j, char in enumerate(line): + if char == 'o': + # Store block position relative to piece center + BLOCKS[j // 5].append((j%5 - 1, -i + 2)) + +# Function to draw a tetromino (falling block) on screen +# Can draw on the main board or in the side panel (preview area) +def draw_block_curses(win, block, x0, y0, use_board=True, draw_x=None, draw_y=None): + """Draw a block either on the main board (use_board=True) or in a side area. + + For board drawing, x0,y0 are board coordinates. For side drawing, set + use_board=False and provide draw_x, draw_y as screen coordinates (columns, rows). + """ + # Iterate through each block cell in the tetromino + for dx, dy in block: + # Calculate actual position by adding offsets to base position + x, y = x0 + dx, y0 + dy + if use_board: + # Draw on main game board + if 0 <= x < W and 0 <= y < H: + # Convert board coordinates to screen coordinates + # (accounting for CELL_W and CELL_H scaling) + screen_row_base = PAD_Y + 1 + (H - 1 - y) * CELL_H + screen_col = PAD_X + x * CELL_W + # Draw the block with height CELL_H (fill multiple rows) + for hh in range(CELL_H): + win.addstr(screen_row_base + hh, screen_col, '█' * CELL_W, curses.color_pair(2)) + else: + # Draw in side panel (for next-piece preview) + if draw_x is None or draw_y is None: + continue + # Calculate side panel position using scaled cell dimensions + sx = draw_x + x * CELL_W + sy = draw_y + (y * CELL_H) + try: + # Draw block with CELL_H rows + for hh in range(CELL_H): + win.addstr(sy + hh, sx, '█' * CELL_W, curses.color_pair(2)) + except Exception: + pass + +# Function to check if a piece can be placed at a given position +# Returns True if the position is valid (no collisions), False otherwise +def can_place_block_clipped(block, x0, y0): + # Check each cell of the piece + for dx, dy in block: + # Calculate actual position + x, y = x0 + dx, y0 + dy + # Check if position is out of bounds or collides with existing blocks + if not (0 <= x < W and 0 <= y) or (y < H and board[y][x]): + return False + return True + + +# Function to lock a piece permanently onto the board +# Marks each cell of the piece as filled +def place_block(block, x0, y0): + # Assume placement is complete initially + complete = True + # Place each block cell on the board + for dx, dy in block: + x, y = x0 + dx, y0 + dy + # Only place if within board bounds (x and y must be valid) + if 0 <= x < W and 0 <= y < H: + board[y][x] = 'blue' # Mark cell as filled with color 'blue' + else: + # If any part extends outside, placement is incomplete (game over) + complete = False + return complete + + + +# Game state variables +# score: player's current score +score = 0 +# t: game tick counter (increments each game loop iteration) +t = 0 +# falling_period: how many ticks before a piece falls one row (decreases with level) +falling_period = 0 +# state: current game state ('normal' = playing, 'game_over' = lost) +state = 'normal' +# block: currently falling piece (list of offsets), next_block: preview of next piece +block = next_block = None +# x, y: current position of the falling piece (x=column, y=row) +x, y = 0, 0 +# falling_generator: iterator that controls piece falling animation +falling_generator = None +# deleting_rows_generator: iterator that controls line-clear animation +deleting_rows_generator = None + + +# Function to move the current falling piece left, right, or down +# dx: horizontal direction (-1=left, +1=right), dy: vertical direction (-1=down) +def move_block(dx, dy): + global x, y + # Calculate new position + new_x, new_y = x + dx, y + dy + # Check if new position is valid (no collisions) + possible = can_place_block_clipped(block, new_x, new_y) + # If valid, update piece position + if possible: + x, y = new_x, new_y + return possible + + +# Function to rotate the current falling piece 90 degrees clockwise +# Rotation formula: (x, y) becomes (-y, x) +def rotate_block(): + global block + # Calculate rotated positions + new_block = [(-y, x) for x, y in block] + # Check if rotated position is valid (no collisions) + possible = can_place_block_clipped(new_block, x, y) + # If valid, update piece with rotated version + if possible: + block = new_block + return possible + + +# Function to spawn the next piece and start it falling +# Also calculates falling speed based on level (score) +def reset_block(): + global falling_period, block, next_block, x, y, state + # Calculate falling period (how many ticks before piece falls): decreases with level + # Higher t (more time) = higher level = faster falling + falling_period = [10, 9, 8, 7, 6, 5, 4][min(t // 100, 6)] + # Swap pieces: current next_block becomes new block, pick random next + new_block = None + while not new_block: + new_block, next_block = next_block, BLOCKS[random.randrange(7)] + # Spawn at center-top of board + x, y = W // 2, H - 1 + # Check for game over: if new piece collides immediately, game is over + if not can_place_block_clipped(new_block, x, y): + state = 'game_over' + return + # Set active piece and start falling animation + block = new_block + begin_falling(falling_period) + + +# Function to create a falling animation generator for a piece +# The piece falls one row every 'period' ticks until it hits bottom +def begin_falling(period): + global falling_generator + def fall(): + global falling_generator + # Main falling loop + while True: + # Wait 'period' ticks before falling next row + for t in range(period): + yield # Pause here and resume on next tick + # Try to move piece down one row + if not move_block(0, -1): + # Piece can't move down, so it has landed + break + # When piece lands, clear the falling generator and lock piece + falling_generator = None + place_block_and_begin_deleting() + yield + # Create and start the generator + falling_generator = fall() + + +# Function to lock the current falling piece onto the board permanently +# Then start checking for completed rows to clear +def place_block_and_begin_deleting(): + global block, state + # Save the current piece before clearing it + old_block = block + block = None # Clear active piece + # Place the piece on the board + if not place_block(old_block, x, y): + # If placement failed (piece extended above board), game over + state = 'game_over' + return + # Start animation for clearing completed rows + begin_deleting_rows() + + +# Function to animate and process line clears +# Completed rows flash and then disappear, with remaining blocks dropping down +def begin_deleting_rows(): + global deleting_rows_generator + # Check if any rows are completed + if any(find_completed_rows()): + # Animation duration based on falling period (faster at higher levels) + duration = falling_period + def delete(): + global deleting_rows_generator, score + # Animate the row clearing (flash between red and blue) + for t in range(duration): + if t in (0, duration // 2): + # Change color of completed rows (red or blue) + color = ('red', 'blue')[t // (duration//2)] + for row in find_completed_rows(): + row[:] = [color] * W + yield # Pause to show animation + # After animation, remove completed rows + incomplete_rows = [row for row in board if not all(row)] + n_deleted = H - len(incomplete_rows) + # Shift remaining rows down and fill with empty rows + board[:] = incomplete_rows + [[None] * W for _ in range(n_deleted)] + # Award points: (number of rows cleared)^2 × 100 + score += (n_deleted**2) * 100 + # Spawn next piece + reset_block() + deleting_rows_generator = None + yield + # Create and start the generator + deleting_rows_generator = delete() + else: + # No completed rows, just spawn next piece immediately + reset_block() + + +# Function called once per game tick (100ms) +# Advances animations (falling piece, line clears) and increments the time counter +def update_stage(): + global t + # Advance the falling animation generator (piece drops) + if block and falling_generator: + next(falling_generator) + # Advance the line-clear animation generator + if deleting_rows_generator: + next(deleting_rows_generator) + # Increment tick counter (used for level calculation) + t += 1 + + +# Function to render (draw) the current game state on the terminal screen +def render_stage_curses(win): + # Clear the screen + win.erase() + # Draw the main game board with borders and filled cells + draw_board_curses(win) + # Display score in the side panel + win.addstr(PAD_Y, SIDE_X, f'SCORE: {score}') + # Display "NEXT:" label + win.addstr(PAD_Y + 2, SIDE_X, 'NEXT:') + # Draw the next piece preview in the side panel + if next_block: + # Normalize block coordinates so preview fits in a compact box + minx = min(dx for dx, dy in next_block) + miny = min(dy for dx, dy in next_block) + # Position preview below the NEXT label + preview_x = SIDE_X + preview_y = PAD_Y + 6 + draw_block_curses(win, next_block, -minx, -miny, use_board=False, draw_x=preview_x, draw_y=preview_y) + # Draw the currently falling piece on the main board + if block: + draw_block_curses(win, block, x, y) + # Display GAME OVER message if the game has ended + if state == 'game_over': + win.addstr(PAD_Y + H//2, PAD_X + W - 4, 'GAME OVER', curses.color_pair(1)) + # Update the screen display + win.refresh() + + +# Input handler: move piece left +# Called when 'a' or left arrow is pressed +def on_key_left(): + if block: + move_block(-1, 0) + +# Input handler: move piece right +# Called when 'd' or right arrow is pressed +def on_key_right(): + if block: + move_block(1, 0) + +# Input handler: soft drop (or lock piece if already at bottom) +# Called when 's' or down arrow is pressed +def on_key_down(): + if block: + if not move_block(0, -1): + place_block_and_begin_deleting() + +# Input handler: hard drop (instant fall to bottom) +# Called when spacebar is pressed +def on_key_space(): + if block: + begin_falling(period=0) # period=0 means fall instantly + +# Input handler: rotate piece 90 degrees +# Called when 'w' or up arrow is pressed +def on_key_up(): + if block: + rotate_block() + + +# Main game function - runs the curses terminal game loop +def main_curses(stdscr): + # Declare globals so we can modify them in this function + global PAD_X, PAD_Y, SIDE_X + + # Curses initialization + curses.curs_set(0) # Hide the cursor + stdscr.nodelay(True) # Non-blocking input (don't wait for keypresses) + stdscr.keypad(True) # Enable special keys (arrow keys, etc.) + curses.start_color() # Enable color support + curses.use_default_colors() # Use terminal's default colors + # Define color pairs: (ID, foreground color, background color) + curses.init_pair(1, curses.COLOR_RED, -1) # Red for GAME OVER text + curses.init_pair(2, curses.COLOR_BLUE, -1) # Blue for game blocks + + # Calculate centered board position based on terminal size + PAD_X, PAD_Y = get_centered_offsets(stdscr) + SIDE_X = PAD_X + W * CELL_W + 6 + + # Initialize the game: spawn first piece + reset_block() + # Game tick rate: 100ms (0.1 seconds) between game updates + tick = 0.1 + # Track time for tick timing + last = time.time() + + # Main game loop + while True: + # Get current time + now = time.time() + # If enough time has passed, do a game update + if now - last >= tick: + # Advance game state (gravity, animations, etc.) + update_stage() + # Redraw the screen with updated state + render_stage_curses(stdscr) + # Reset timer for next tick + last = now + + # Check for keyboard input (non-blocking) + try: + ch = stdscr.getch() + except Exception: + ch = -1 + + # Process keyboard input + if ch != -1: + # Quit game + if ch in (ord('q'), ord('Q')): + break + # Move left + elif ch in (curses.KEY_LEFT, ord('a')): + on_key_left() + # Move right + elif ch in (curses.KEY_RIGHT, ord('d')): + on_key_right() + # Soft drop + elif ch in (curses.KEY_DOWN, ord('s')): + on_key_down() + # Rotate + elif ch in (curses.KEY_UP, ord('w')): + on_key_up() + # Hard drop + elif ch == ord(' '): + on_key_space() + + # Small sleep to avoid consuming 100% CPU + time.sleep(0.001) + +# Entry point: run the game using curses wrapper (handles cleanup automatically) +if __name__ == '__main__': \ No newline at end of file diff --git a/proposal.md b/proposal.md new file mode 100644 index 0000000..9a64528 --- /dev/null +++ b/proposal.md @@ -0,0 +1,27 @@ +## Game name + +Tessera + +## Vision + +Tessera takes classic Tetris and adds a light puzzle layer: arranged clears that match colors grant small bonuses, rewarding planning without changing the tight, fast arcade feel. It's worth making because it blends a familiar, addictive core with a gentle twist that increases depth for skilled players while staying approachable. + +## What the game will look like + +A clean, modern pixel/flat aesthetic with bright, readable tetromino colors on a dark background; the HUD shows next pieces, hold slot, score, level, and subtle particle effects on clears. + +## Player interaction + +Players use keyboard controls to move, rotate, soft/hard drop, and hold pieces; menus use simple mouse/keyboard navigation. The game emphasizes quick decision-making and responsive controls with a visible ghost piece to aid placement. + +## Core mechanics + +1. Falling tetromino placement and rotation — move and rotate pieces to fit them into the well. +2. Line clearing and gravity — completed rows clear and the above rows shift down, possibly chaining combos. +3. Hold and next-queue — store one piece and preview upcoming pieces to plan ahead. +4. Color-match bonus (optional) — clearing lines where most blocks share a color grants small score/energy bonuses to encourage pattern play. + +## Milestone (first target) + +Implement the core game engine: represent the board and pieces, support spawning, movement, rotation, collision detection, locking, and single-line clearing so you can run a minimal playable loop and unit tests for the engine. + diff --git a/test_game.py b/test_game.py new file mode 100644 index 0000000..3a45dd5 --- /dev/null +++ b/test_game.py @@ -0,0 +1,142 @@ +import inspect +import os +import random +import sys +import retro +import tetris +from retro.agent import ArrowKeyAgent + + + +# Expose Game name expected by create_game helper +game = tetris.Tetris() + + +# --- Factory helpers and small harness --- + +def create_game(): + """Create a Game instance with minimal sensible defaults.""" + try: + return Game() + except TypeError: + try: + sig = inspect.signature(Game) + kwargs = {} + if 'agents' in sig.parameters: + kwargs['agents'] = [] + if 'state' in sig.parameters: + kwargs['state'] = {} + return Game(**kwargs) + except Exception: + for args in (([], {}), ([], None), ([],), (None,)): + try: + return Game(*args) + except Exception: + continue + raise + + +def create_agent(game=None): + try: + return ArrowKeyAgent(game) + except TypeError: + return ArrowKeyAgent() + + +def draw_screen(game: Game): + # simple text render + game.clear() + s = game.draw_board() + game.refresh() + # store/return the string for tests + return s + + +def main(): + argv = sys.argv[1:] + if len(argv) >= 1 and argv[0] == 'play': + # Try to run a terminal player. Use curses if available. + try: + play_in_terminal(create_game()) + except Exception as e: + print('Terminal play failed:', e) + print('Run without args to run smoke tests instead.') + return + + game = create_game() + agent = create_agent(game) + + while not game.is_over(): + action = agent.get_action() + game.step(action) + draw_screen(game) + + print('Game Over! Score:', game.get_score()) + +def play_in_terminal(game): + """Simple curses-based player loop. + + Controls: + - Left/Right arrows or 'a'/'d' to move + - Up arrow or 'w' to rotate + - Down arrow or 's' to soft drop + - Space for hard drop + - 'q' to quit + """ + + keymap = { + # map keys to the Testris.action integer codes: + # 0 noop, 1 left, 2 right, 3 rotate, 4 soft drop + curses.KEY_LEFT: 1, + curses.KEY_RIGHT: 2, + curses.KEY_UP: 3, + curses.KEY_DOWN: 4, + ord('a'): 1, + ord('d'): 2, + ord('w'): 3, + ord('s'): 4, + # space will perform a hard drop (special handling) + ord(' '): 'hard_drop', + ord('q'): 'quit', + } + + + + + + + +# --- Simple smoke tests --- + +def test_game_initialization(): + g = create_game() + assert g is not None + assert g.get_score() == 0 + assert not g.is_over() + + +def test_game_step(): + g = create_game() + score0 = g.get_score() + g.step(0) + assert g.get_score() >= score0 + + +def test_draw_board_returns_str(): + g = create_game() + s = g.draw_board() + assert isinstance(s, str) + + +def test_game_over_sequence(): + g = create_game() + # run a few steps to ensure no immediate crash + for _ in range(50): + if g.is_over(): + break + g.step(0) + assert g.get_score() >= 0 + + +if __name__ == '__main__': + main() diff --git a/tetris.py b/tetris.py new file mode 100644 index 0000000..d93ea57 --- /dev/null +++ b/tetris.py @@ -0,0 +1,123 @@ +import random + + +class Tetris: + W = 10 + H = 20 + + PIECES = { + 'I': [[(0,1),(1,1),(2,1),(3,1)], [(2,0),(2,1),(2,2),(2,3)]], + 'O': [[(1,0),(2,0),(1,1),(2,1)]], + 'T': [[(1,0),(0,1),(1,1),(2,1)], [(1,0),(1,1),(2,1),(1,2)], [(0,1),(1,1),(2,1),(1,2)], [(1,0),(0,1),(1,1),(1,2)]], + 'L': [[(2,0),(0,1),(1,1),(2,1)], [(1,0),(1,1),(1,2),(2,2)], [(0,1),(1,1),(2,1),(0,2)], [(0,0),(1,0),(1,1),(1,2)]], + 'J': [[(0,0),(0,1),(1,1),(2,1)], [(1,0),(2,0),(1,1),(1,2)], [(0,1),(1,1),(2,1),(2,2)], [(1,0),(1,1),(1,2),(0,2)]], + 'S': [[(1,0),(2,0),(0,1),(1,1)], [(1,0),(1,1),(2,1),(2,2)]], + 'Z': [[(0,0),(1,0),(1,1),(2,1)], [(2,0),(1,1),(2,1),(1,2)]], + } + + def __init__(self, agents=None, state=None): + self.score = 0 + self.over = False + self.grid = [[0] * self.W for _ in range(self.H)] + self.rng = random.Random(0) + self.next_piece = self._rand_piece() + self._spawn() + self.last_render = '' + + def _rand_piece(self): + return self.rng.choice(list(self.PIECES.keys())) + + def _spawn(self): + self.piece = self.next_piece + self.next_piece = self._rand_piece() + self.rot = 0 + self.px = (self.W // 2) - 2 + self.py = 0 + if not self._fits(self.px, self.py, self.rot): + self.over = True + + def _cells(self, px, py, rot): + rstates = self.PIECES[self.piece] + r = rot % len(rstates) + return [(px + x, py + y) for (x, y) in rstates[r]] + + def _fits(self, px, py, rot): + for x, y in self._cells(px, py, rot): + if x < 0 or x >= self.W or y < 0 or y >= self.H: + return False + if self.grid[y][x]: + return False + return True + + def _lock(self): + for x, y in self._cells(self.px, self.py, self.rot): + if 0 <= y < self.H and 0 <= x < self.W: + self.grid[y][x] = 1 + # clear lines + newg = [row for row in self.grid if not all(row)] + cleared = self.H - len(newg) + if cleared: + for _ in range(cleared): + newg.insert(0, [0] * self.W) + self.grid = newg + self.score += 100 * cleared + self._spawn() + + # API methods used by tests / agent + def step(self, action=0): + # actions: 0 noop, 1 left, 2 right, 3 rotate, 4 soft drop + if self.over: + return + if action == 1: + if self._fits(self.px - 1, self.py, self.rot): + self.px -= 1 + elif action == 2: + if self._fits(self.px + 1, self.py, self.rot): + self.px += 1 + elif action == 3: + if self._fits(self.px, self.py, self.rot + 1): + self.rot += 1 + elif action == 4: + if self._fits(self.px, self.py + 1, self.rot): + self.py += 1 + else: + self._lock() + return + # gravity + if self._fits(self.px, self.py + 1, self.rot): + self.py += 1 + else: + self._lock() + + def is_over(self): + return bool(self.over) + + def get_score(self): + return int(self.score) + + # rendering stubs (retro compatibility) + def clear(self): + pass + + def draw_board(self): + lines = [] + occupied = set(self._cells(self.px, self.py, self.rot)) if not self.over else set() + for y in range(self.H): + row = '' + for x in range(self.W): + if (x, y) in occupied: + row += '[]' + else: + row += '##' if self.grid[y][x] else '..' + lines.append(row) + self.last_render = '\n'.join(lines) + return self.last_render + + def draw_piece(self): + return + + def refresh(self): + return + + def reset(self): + self.__init__() \ No newline at end of file