Created
May 21, 2025 20:40
-
-
Save koaning/091248903d844e76db601fc6b9afd5db to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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