Files
project_game/cursor/play_game2.py
Chris Proctor c8d1ddd58f Started retro tetris game
Started developing a retro version of Tetris, following
the planning on the board of the classroom
(see planning.jpg).

Moved cursor work into cursor.
2025-12-18 17:45:39 -05:00

452 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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__':