Skip to content

Instantly share code, notes, and snippets.

@koaning
Created May 21, 2025 20:40
Show Gist options
  • Save koaning/091248903d844e76db601fc6b9afd5db to your computer and use it in GitHub Desktop.
Save koaning/091248903d844e76db601fc6b9afd5db to your computer and use it in GitHub Desktop.
import curses
import time
import random
MAX_STARS = 50
STAR_CHARS = ['.', '*']
EXPLOSION_DURATION = 3 # Frames
EXPLOSION_ART_PARTS = [ # New name, list of lists of (char, color_key) tuples
[(' ', None), (' ', None), ('`', "exp_yellow"), ('*', "exp_yellow"), ('`', "exp_yellow"), (' ', None), (' ', None)],
[(' ', None), ('*', "exp_dark_red"), ('X', "exp_yellow"), ('X', "exp_yellow"), ('X', "exp_yellow"), ('*', "exp_dark_red"), (' ', None)],
[(' ', None), (' ', None), (',', "exp_dark_red"), ('*', "exp_yellow"), (',', "exp_dark_red"), (' ', None), (' ', None)]
]
EXPLOSION_WIDTH = 7 # Max length of any line of characters, should be consistent
EXPLOSION_HEIGHT = len(EXPLOSION_ART_PARTS)
ENEMY_BASE_SPEED = 0.3 # Cells per frame
STAR_MIN_SPEED = 0.05
STAR_MAX_SPEED = 0.25
ENEMY_TYPES = {
"grunt": {
"art": ["vVv"],
"width": 3,
"height": 1,
"speed_modifier": 1.0,
"color_key": "red"
},
"mutalisk_inspired": { # Renamed from zergling_inspired
"art": [
" ~^~ ", # Padded to width 7
"-(o.o)-",
" ~V~ " # Padded to width 7
],
"width": 7,
"height": 3,
"speed_modifier": 0.7,
"color_key": "purple"
},
"probe_inspired": {
"art": [
" o ",
"<--->",
" V "
],
"width": 5,
"height": 3,
"speed_modifier": 0.8,
"color_key": "purple"
}
}
def main(stdscr):
# Clear screen
stdscr.clear()
curses.curs_set(0) # Hide the cursor
# stdscr.nodelay(1) # Make getch non-blocking (superseded by timeout)
stdscr.timeout(0) # Make getch non-blocking (returns -1 if no input immediately)
# Initialize colors if supported
if curses.has_colors():
curses.start_color()
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) # Player color
curses.init_pair(2, curses.COLOR_RED, curses.COLOR_BLACK) # Enemy color
curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) # Projectile color
curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_BLACK) # Bright Star
curses.init_pair(5, curses.COLOR_RED, curses.COLOR_BLACK) # Old Explosion color (now general red if needed)
curses.init_pair(6, curses.COLOR_MAGENTA, curses.COLOR_BLACK) # Purple for larger ships
curses.init_pair(7, curses.COLOR_RED, curses.COLOR_BLACK) # exp_dark_red
curses.init_pair(8, curses.COLOR_YELLOW, curses.COLOR_BLACK) # exp_yellow
player_color = curses.color_pair(1) if curses.has_colors() else curses.A_NORMAL
enemy_color_red = curses.color_pair(2) if curses.has_colors() else curses.A_NORMAL
projectile_color = curses.color_pair(3) if curses.has_colors() else curses.A_NORMAL
star_color_bright = curses.color_pair(4) if curses.has_colors() else curses.A_NORMAL
star_color_dim = curses.A_DIM if curses.has_colors() else curses.A_NORMAL
# explosion_color_attr is no longer a single attribute for the whole explosion
enemy_color_purple = curses.color_pair(6) if curses.has_colors() else curses.A_NORMAL
exp_color_dark_red = curses.color_pair(7) if curses.has_colors() else curses.A_NORMAL # Fallback if no colors
exp_color_yellow = curses.color_pair(8) if curses.has_colors() else curses.A_BOLD # Fallback if no colors
# Map color keys to actual color attributes
color_map = {
"red": enemy_color_red,
"purple": enemy_color_purple,
"exp_dark_red": exp_color_dark_red,
"exp_yellow": exp_color_yellow,
None: curses.A_NORMAL # For spaces or uncolored parts
}
# Initial screen dimensions for setup
_sh, _sw = stdscr.getmaxyx()
score = 0 # Initialize score
# Player setup
player_art = "/=\\"
player_width = len(player_art)
player_x = _sw // 2 - player_width // 2
player_y = _sh - 2
# Enemy setup
enemies = []
# enemy_art, enemy_width are now per-enemy instance
enemy_spawn_counter_max = int(25 * (0.3 / ENEMY_BASE_SPEED)) # Adjusted slightly
enemy_spawn_counter = 0
# Bullet setup
bullets = []
bullet_char = "|"
# Starfield setup
stars = []
for _ in range(MAX_STARS // 2):
stars.append({
'x': random.randint(0, _sw - 1),
'y': random.uniform(0, _sh -1),
'char': random.choice(STAR_CHARS),
'speed_factor': random.uniform(STAR_MIN_SPEED, STAR_MAX_SPEED),
'color_attr': star_color_dim if random.random() < 0.7 else star_color_bright
})
# Explosion setup
explosions = []
game_over = False # Game state
# Game loop
while not game_over:
# Get screen dimensions each frame to handle resize
sh, sw = stdscr.getmaxyx()
# Update player position if screen was resized too small and player is off-screen
# This is a basic handling, more sophisticated logic might be needed for drastic resizes
player_x = max(0, min(player_x, sw - player_width))
player_y = sh - 2 # Keep player at bottom
try:
key = stdscr.getch() # Will now return -1 immediately if no key pressed
except curses.error: # Can happen on resize
key = -1 # No input
# Handle input
if key == curses.KEY_LEFT and player_x > 0:
player_x -= 1
elif key == curses.KEY_RIGHT and player_x < sw - player_width:
player_x += 1
# Separate check for shooting to allow move and shoot feel more simultaneous
if key == curses.KEY_UP: # Shoot
# Spawn bullet from the center of the player, above the art
bullet_x = player_x + player_width // 2
bullet_y = player_y -1
if bullet_y >= 0: # Only spawn if it's on screen
bullets.append({'x': bullet_x, 'y': bullet_y, 'char': bullet_char})
elif key == ord('q'): # Quit game
break
# --- Game Logic ---
# Enemy Spawning
enemy_spawn_counter += 1
if enemy_spawn_counter >= enemy_spawn_counter_max:
enemy_spawn_counter = 0
# Randomly select an enemy type
chosen_type_key = random.choice(list(ENEMY_TYPES.keys()))
enemy_type = ENEMY_TYPES[chosen_type_key]
current_enemy_width = enemy_type["width"]
current_enemy_height = enemy_type["height"]
current_enemy_speed = ENEMY_BASE_SPEED * enemy_type["speed_modifier"]
current_enemy_color = color_map.get(enemy_type["color_key"], enemy_color_red) # Default to red
if sw > current_enemy_width: # Ensure screen is wide enough for the enemy
new_enemy_x = random.randint(0, sw - current_enemy_width -1)
enemies.append({
'x': new_enemy_x,
'y': 1.0, # y is float for smooth movement, start at row 1 to not overlap score
'art_lines': enemy_type["art"],
'width': current_enemy_width,
'height': current_enemy_height,
'speed': current_enemy_speed, # Store individual speed
'color': current_enemy_color, # Store individual color
'type': chosen_type_key # Store type for potential future use (e.g. behavior)
})
# Enemy Movement & Despawning
active_enemies = []
for enemy in enemies:
enemy['y'] += enemy['speed'] # Move down by its specific speed
if int(enemy['y']) < sh: # If still on screen (using int for comparison)
active_enemies.append(enemy)
enemies = active_enemies
# Bullet Movement & Despawning
active_bullets = []
for bullet in bullets:
bullet['y'] -= 1 # Move up
if bullet['y'] >= 0: # If still on screen
active_bullets.append(bullet)
bullets = active_bullets
# Starfield movement
for star in stars:
star['y'] += star['speed_factor']
if star['y'] >= sh: # Use >= to catch stars exactly on the edge or past
star['y'] = 0
star['x'] = random.randint(0, sw - 1)
elif star['y'] < 0: # Should not happen with current logic but good practice
star['y'] = sh -1
# --- Collision Detection ---
# Bullet-Enemy Collisions
bullets_to_remove = set()
enemies_to_remove = set()
for i, bullet in enumerate(bullets):
for j, enemy in enumerate(enemies):
# Check if bullet x is within enemy's x-range and y matches
# TODO: Adjust collision for multi-line enemies (y-range and art height)
if enemy['x'] <= bullet['x'] < enemy['x'] + enemy['width'] and bullet['y'] == int(enemy['y']): # Simple collision for now
bullets_to_remove.add(i)
enemies_to_remove.add(j)
score += 10 # Increment score
# Add explosion - TODO: Center explosion based on multi-line enemy height
explosion_x_start = enemy['x'] + enemy['width'] // 2 - EXPLOSION_WIDTH // 2
explosion_y_start = int(enemy['y']) + enemy['height'] // 2 - EXPLOSION_HEIGHT // 2 # Centered on enemy's visual center
explosions.append({
'x': explosion_x_start,
'y': explosion_y_start,
'art_parts': EXPLOSION_ART_PARTS, # Store the list of lists of tuples
'timer': EXPLOSION_DURATION
# 'color' key is no longer needed here as it's per-character part
})
# Remove collided bullets and enemies
bullets = [b for i, b in enumerate(bullets) if i not in bullets_to_remove]
enemies = [e for i, e in enumerate(enemies) if i not in enemies_to_remove]
# Player-Enemy Collisions
for enemy in enemies:
# Check for overlap in x and y (since both player and enemy are on single lines)
# TODO: Adjust collision for multi-line enemies
if int(enemy['y']) == player_y: # Simple collision for now, assumes enemy is 1 line high for player collision
# Check for x-overlap
if not (player_x + player_width <= enemy['x'] or player_x >= enemy['x'] + enemy['width']):
game_over = True
break # Exit enemy loop
if game_over: # If game over from inner loop, break outer too
break
# Update explosions
active_explosions = []
for explosion in explosions:
explosion['timer'] -= 1
if explosion['timer'] > 0:
active_explosions.append(explosion)
explosions = active_explosions
# --- Drawing ---
stdscr.clear() # Clear screen before redrawing
# Draw Stars
for star in stars:
# Ensure star coords are valid before drawing, especially after resize
# Avoid last row and last column to prevent add_wch error on some terminals
if sh > 1 and sw > 1: # Only attempt to draw if screen is larger than 1x1
if 0 <= int(star['y']) < (sh - 1) and 0 <= star['x'] < (sw - 1):
stdscr.addch(int(star['y']), star['x'], star['char'], star['color_attr'])
# Draw Explosions
for explosion in explosions:
for i, line_parts in enumerate(explosion['art_parts']):
draw_y = explosion['y'] + i
for j, part_tuple in enumerate(line_parts):
char_to_draw, color_key = part_tuple
if char_to_draw != ' ': # Don't draw spaces if they are just for padding
# Ensure each char of the explosion is within bounds
draw_x = explosion['x'] + j
if 0 <= draw_y < sh and 0 <= draw_x < sw : # Simple bounds for individual char
stdscr.addstr(draw_y, draw_x, char_to_draw, color_map.get(color_key, curses.A_NORMAL))
# Draw player
# Ensure player is within bounds before drawing, especially after resize
if 0 <= player_y < sh and 0 <= player_x < sw and player_x + player_width <= sw:
stdscr.addstr(player_y, player_x, player_art, player_color)
# Draw enemies
for enemy in enemies:
for idx, line in enumerate(enemy['art_lines']):
draw_y = int(enemy['y']) + idx
# Ensure each line of the enemy is within bounds before drawing
# More conservative check to avoid edge errors, similar to stars
if sh > 1 and sw > 1: # Basic check if screen is usable
if 0 <= draw_y < (sh -1) and 0 <= enemy['x'] < (sw -1) and (enemy['x'] + len(line)) <= (sw -1):
stdscr.addstr(draw_y, enemy['x'], line, enemy['color']) # Use enemy's specific color
# Draw bullets
for bullet in bullets:
if 0 <= bullet['y'] < sh and 0 <= bullet['x'] < sw:
stdscr.addstr(bullet['y'], bullet['x'], "|", projectile_color)
# Display Score - draw this last or after clearing parts of the screen
# Ensure it doesn't get overwritten and fits
score_text = f"Score: {score}"
if sw > len(score_text): # Check if screen is wide enough for score
stdscr.addstr(0, 0, score_text.ljust(sw -1), curses.A_REVERSE) # Display score at top-left, padded, reversed color
# Refresh screen
stdscr.refresh()
# Cap frame rate
time.sleep(0.016) # Target ~60 FPS
# Game Over Screen
stdscr.clear()
sh, sw = stdscr.getmaxyx()
game_over_text = "GAME OVER"
game_over_x = sw // 2 - len(game_over_text) // 2
game_over_y = sh // 2
stdscr.addstr(game_over_y, game_over_x, game_over_text)
stdscr.refresh()
time.sleep(3) # Show game over message for 3 seconds
if __name__ == '__main__':
curses.wrapper(main)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment