diff --git a/README.md b/README.md index e69de29..662fa41 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,102 @@ +# TANK BATTLEZONE + +Un juego de combate de tanques en 3D retro con gráficos vectoriales al estilo de la mítica máquina arcade *BattleZone* (1980), desarrollado en Python y Pygame. + +--- + +## 📖 Descripción + +**Tank BattleZone** recrea la experiencia arcade de combate vectorial tridimensional en un entorno acotado. + +Pilotas un tanque táctico en un mapa plagado de pirámides obstáculo, enfrentándote a hordas progresivas de tanques enemigos con distintas habilidades y buscando recargar tus escudos destruyendo OVNIs y recogiendo potenciadores. + +Todos los gráficos se renderizan mediante proyección matemática 3D a 2D en líneas vectoriales puras, y el audio (disparos, explosiones, motor y escudos) se sintetiza dinámicamente mediante software sin requerir archivos de audio externos. + +--- + +## ✨ Características Principales + +* 📐 **Renderizado Vectorial 3D Reales:** Proyección de perspectiva 3D personalizada mediante ecuaciones de álgebra vectorial. +* 🤖 **Inteligencia Artificial Enemiga:** Varios tipos de tanques enemigos con comportamiento de acecho, orientación de torreta independiente y patrulla. +* 🛸 **Eventos Dinámicos:** Aparición periódica de OVNIs cruzadores que otorgan recompensas críticas. +* 🔊 **Síntesis de Audio PCM:** Efectos de sonido retro sintetizados por software en tiempo real (procedimentales). +* 🕹️ **Pantalla Inicial Descriptiva:** Menú de inicio interactivo con previsualización gráfica de todos los elementos vectoriales del juego. +* 🐧 **Soporte Nativo para Linux:** Diseñado y optimizado para ejecutarse en distribuciones basadas en Debian. + +--- + +## 🛠️ Requisitos + +### Requisitos de Software +* **Python:** 3.8 o superior. +* **Pygame:** 2.0.0 o superior. + +--- + +## ⚙️ Instalación y Ejecución + +Sigue estos pasos para instalar las dependencias e iniciar el juego en tu sistema Debian: + +1. Clonar o descargar el repositorio + +```bash +git clone https://gitea.soloconlinux.org.es/luisgulo/Tank_BattleZone.git +cd Tank_BattleZone +``` + +2. Instalar dependencias en Debian + +Asegúrate de tener Python 3 y pip instalados, e instala la librería pygame: + +```bash +sudo apt update +sudo apt -y install python3 python3-pip python3-pygame +``` + +(Opcional: Si prefieres usar un entorno virtual) + +```bash +python3 -m venv venv +source venv/bin/activate +pip install pygame +``` + +3. Ejecutar el juego + +Lanza el script principal del juego desde tu terminal: + +```bash +python3 tank_battlezone.py +``` + +## 🕹️ Cómo Jugar + +⌨️ Controles + +| Acción | Tecla / Control | +| --- | --- | +|Avanzar / Retroceder | Flecha Arriba / W \| Flecha Abajo | / S | +|Girar Tanque | Flecha Izquierda / A | Flecha Derecha / D| +|Disparar Cañón|Barra Espaciadora| +|Comenzar / Reiniciar|Intro (Menú) / R (Tras Game Over)| + +🎯 Elementos del Juego +|Elemento | Descripción |Puntos / Efecto| +| --- | --- | --- | +|🟩 Pirámide verde|Obstáculo infranqueable. Bloquea el paso y los disparos.|—| +|🔴 Tanque Rojo|Enemigo básico. Requiere 1 impacto.|+1,000 Pts| +|🔵 Tanque Azul|Enemigo intermedio. Requiere 2 impactos.|+2,500 Pts| +|🟣 Tanque Púrpura|Enemigo pesado. Requiere 3 impactos. Puede soltar un potenciador.|+5,000 Pts| +|🟡 Diamante Amarillo|Potenciador de escudo flotante.|+1 Escudo / +500 Pts| +|🛸 OVNI Amarillo|Objeto volador no identificado cruzador.|Recarga ESCUDO MÁXIMO / +3,000 Pts| + +📁 Estructura del Proyecto + +``` +Tank_BattleZone/ +├── battlezone.py # Código fuente principal del juego (Pygame) +└── README.md # Documentación del proyecto +``` + + + diff --git a/tank_battlezone.py b/tank_battlezone.py new file mode 100755 index 0000000..3e56bf2 --- /dev/null +++ b/tank_battlezone.py @@ -0,0 +1,973 @@ +#!/usr/bin/env python3 +import math +import random +import sys +import array +import pygame + +# Inicialización de Pygame y mezclador de audio +pygame.init() +SAMPLE_RATE = 22050 +pygame.mixer.init(frequency=SAMPLE_RATE, size=-16, channels=1, buffer=512) + +# Configuración de pantalla +WIDTH, HEIGHT = 800, 600 +FOV = 320 # Distancia focal +SCREEN = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Tank BattleZone") +CLOCK = pygame.time.Clock() +FPS = 60 + +# Configuración de cámara y visor +CAMERA_HEIGHT = 1.2 +HORIZON_Y = int(HEIGHT * 0.58) + +# Colores Retro Vectoriales +COLOR_BG = (10, 15, 10) +COLOR_GREEN = (0, 255, 66) +COLOR_DARK_GREEN = (0, 80, 20) +COLOR_RED = (255, 40, 40) +COLOR_BLUE = (0, 150, 255) +COLOR_PURPLE = (180, 0, 255) +COLOR_YELLOW = (255, 230, 0) +COLOR_WHITE = (255, 255, 255) + + +# --- GENERADOR SINTÉTICO DE EFECTOS DE SONIDO --- +def create_sound_sample(generator_func, duration): + """Crea un objeto pygame.mixer.Sound generando muestras de audio PCM.""" + num_samples = int(SAMPLE_RATE * duration) + buf = array.array("h") + for i in range(num_samples): + t = i / SAMPLE_RATE + val = generator_func(t, i, num_samples) + val = max(-32768, min(32767, int(val))) + buf.append(val) + return pygame.mixer.Sound(buffer=buf) + + +# 1. Sonido de disparo (Frecuencia descendente) +def gen_shoot(t, i, total): + freq = 600 * (1.0 - t / 0.15) + env = 1.0 - (t / 0.15) + return math.sin(2 * math.pi * freq * t) * 20000 * max(0, env) + + +SOUND_SHOOT = create_sound_sample(gen_shoot, 0.15) + + +# 2. Sonido de impacto enemigo (Explosión / Ruido blanco con caída) +def gen_explosion(t, i, total): + env = (1.0 - t / 0.4) ** 2 + noise = random.uniform(-1.0, 1.0) + return noise * 28000 * max(0, env) + + +SOUND_EXPLOSION = create_sound_sample(gen_explosion, 0.4) + + +# 3. Sonido de impacto en nuestro tanque (Golpe seco metálico) +def gen_hit_player(t, i, total): + freq = 90 * math.exp(-t * 15) + env = math.exp(-t * 12) + noise = random.uniform(-0.3, 0.3) + return (math.sin(2 * math.pi * freq * t) + noise) * 30000 * max(0, env) + + +SOUND_HIT_PLAYER = create_sound_sample(gen_hit_player, 0.35) + + +# 4. Sonido de rebote u obstáculo (Clink metálico agudo) +def gen_clink(t, i, total): + freq = 1800 + env = math.exp(-t * 35) + return math.sin(2 * math.pi * freq * t) * 18000 * max(0, env) + + +SOUND_CLINK = create_sound_sample(gen_clink, 0.08) + + +# 5. Sonido de recarga de escudo (Tono ascendente armónico) +def gen_powerup(t, i, total): + freq = 400 + (t / 0.25) * 800 + env = math.sin(math.pi * (t / 0.25)) + return math.sin(2 * math.pi * freq * t) * 22000 * max(0, env) + + +SOUND_POWERUP = create_sound_sample(gen_powerup, 0.25) + + +# 6. Sonido espaciado para movimiento y giro +def gen_tread_click(t, i, total): + period = 0.12 # Intervalo de tiempo entre pulsos + phase = t % period + if phase < 0.015: + freq = 120 + env = math.sin(math.pi * (phase / 0.015)) + noise = random.uniform(-0.3, 0.3) + return (math.sin(2 * math.pi * freq * phase) + noise) * 8000 * env + return 0 + + +SOUND_MOVEMENT = create_sound_sample(gen_tread_click, 0.24) + +# Canal de audio para el movimiento/giro +CHANNEL_MOVEMENT = pygame.mixer.Channel(0) + + +def project_3d(x, y, z, player_x, player_y, player_angle): + """Proyecta un punto 3D del mundo a coordenadas 2D relativas a la visión del jugador.""" + dx = x - player_x + dy = y - player_y + + cos_a = math.cos(player_angle) + sin_a = math.sin(player_angle) + + rx = dx * cos_a - dy * sin_a + ry = dx * sin_a + dy * cos_a + + if ry <= 0.2: + return None + + screen_x = int(WIDTH / 2 + (rx * FOV) / ry) + screen_y = int(HORIZON_Y - ((z - CAMERA_HEIGHT) * FOV) / ry) + + return (screen_x, screen_y) + + +def draw_vector_shape(surface, points_3d, color, p_x, p_y, p_angle, closed=True): + """Dibuja una figura 3D compuesta por segmentos vectoriales.""" + pts_2d = [] + for pt in points_3d: + p2d = project_3d(pt[0], pt[1], pt[2], p_x, p_y, p_angle) + if p2d is None: + return + pts_2d.append(p2d) + + if len(pts_2d) > 1: + if closed: + pygame.draw.polygon(surface, color, pts_2d, 1) + else: + pygame.draw.lines(surface, color, False, pts_2d, 1) + + +# --- FUNCIONES AUXILIARES PARA DIBUJAR ICONOS 2D/3D EN PANTALLA DE INICIO --- +def draw_pyramid_icon(surface, cx, cy): + size, height = 18, 22 + apex = (cx, cy - height) + base = [ + (cx - size, cy + 8), + (cx + size, cy + 8), + (cx + size - 6, cy - 4), + (cx - size + 6, cy - 4) + ] + pygame.draw.polygon(surface, COLOR_DARK_GREEN, base, 1) + for b in base: + pygame.draw.line(surface, COLOR_GREEN, b, apex, 1) + + +def draw_tank_icon(surface, cx, cy, color): + # Oruga izquierda / derecha + pygame.draw.rect(surface, color, (cx - 16, cy - 10, 6, 20), 1) + pygame.draw.rect(surface, color, (cx + 10, cy - 10, 6, 20), 1) + # Cuerpo central + pygame.draw.rect(surface, color, (cx - 10, cy - 8, 20, 16), 1) + # Torreta + pygame.draw.rect(surface, color, (cx - 6, cy - 5, 12, 10), 1) + # Cañón + pygame.draw.line(surface, color, (cx, cy - 5), (cx, cy - 18), 2) + + +def draw_diamond_icon(surface, cx, cy): + size = 12 + pts = [(cx, cy - size), (cx + size, cy), (cx, cy + size), (cx - size, cy)] + pygame.draw.polygon(surface, COLOR_YELLOW, pts, 1) + pygame.draw.line(surface, COLOR_YELLOW, (cx, cy - size), (cx, cy + size), 1) + pygame.draw.line(surface, COLOR_YELLOW, (cx - size, cy), (cx + size, cy), 1) + + +def draw_ufo_icon(surface, cx, cy): + # Anillo exterior + pygame.draw.ellipse(surface, COLOR_YELLOW, (cx - 18, cy - 6, 36, 12), 1) + # Cúpula superior + pygame.draw.arc(surface, COLOR_YELLOW, (cx - 10, cy - 12, 20, 12), 0, math.pi, 1) + # Cúpula inferior + pygame.draw.arc(surface, COLOR_YELLOW, (cx - 8, cy - 2, 16, 10), math.pi, 2 * math.pi, 1) + + +class Pyramid: + + def __init__(self, x, y, size=3.0, height=4.0): + self.x = x + self.y = y + self.size = size + self.height = height + self.radius = size / 1.8 + + def draw(self, surface, p_x, p_y, p_angle): + s = self.size / 2 + h = self.height + apex = (self.x, self.y, h) + base = [ + (self.x - s, self.y - s, 0), + (self.x + s, self.y - s, 0), + (self.x + s, self.y + s, 0), + (self.x - s, self.y + s, 0), + ] + + draw_vector_shape(surface, base, COLOR_DARK_GREEN, p_x, p_y, p_angle, True) + for b in base: + draw_vector_shape( + surface, [b, apex], COLOR_GREEN, p_x, p_y, p_angle, False + ) + + +class ShieldPowerup: + + def __init__(self, x, y): + self.x = x + self.y = y + self.z = 1.0 + self.radius = 1.2 + self.alive = True + self.rot = 0.0 + + def update(self): + self.rot += 0.05 + + def draw(self, surface, p_x, p_y, p_angle): + # Diamante flotante amarillo giratorio + s = 0.8 + h = 0.8 + cos_r = math.cos(self.rot) + sin_r = math.sin(self.rot) + + local_pts = [ + (-s, 0, 0), (0, -s, 0), (s, 0, 0), (0, s, 0) + ] + base_3d = [] + for lx, ly, lz in local_pts: + rx = lx * cos_r - ly * sin_r + ry = lx * sin_r + ly * cos_r + base_3d.append((self.x + rx, self.y + ry, self.z)) + + top = (self.x, self.y, self.z + h) + bottom = (self.x, self.y, self.z - h) + + draw_vector_shape(surface, base_3d, COLOR_YELLOW, p_x, p_y, p_angle, True) + for pt in base_3d: + draw_vector_shape(surface, [pt, top], COLOR_YELLOW, p_x, p_y, p_angle, False) + draw_vector_shape(surface, [pt, bottom], COLOR_YELLOW, p_x, p_y, p_angle, False) + + +class UFO: + + def __init__(self, player_x, player_y, player_angle): + self.alive = True + self.radius = 2.0 + self.score_value = 3000 + + # Se engendra a una distancia fija al frente y se desplaza perpendicularmente + distance = 35.0 + direction = random.choice([-1, 1]) # -1: de derecha a izquierda, 1: de izquierda a derecha + + # Posición central frente a la mirada del jugador + center_x = player_x + math.sin(player_angle) * distance + center_y = player_y + math.cos(player_angle) * distance + + # Perpendicular a la dirección de la cámara + perp_angle = player_angle + math.pi / 2 + offset = 30.0 * direction + + self.x = center_x - math.sin(perp_angle) * offset + self.y = center_y - math.cos(perp_angle) * offset + self.z = 2.5 + + speed = 0.12 + self.vx = math.sin(perp_angle) * offset / abs(offset) * speed + self.vy = math.cos(perp_angle) * offset / abs(offset) * speed + + # Contador de vida útil para desaparecer tras cruzar la pantalla + self.lifetime = 500 + + def update(self): + self.x += self.vx + self.y += self.vy + self.lifetime -= 1 + if self.lifetime <= 0: + self.alive = False + + def draw(self, surface, p_x, p_y, p_angle): + # Dibuja la silueta de Platillo Volador / OVNI vectorial retro + r = 1.8 + h = 0.6 + + # Anillo exterior + ring_local = [ + (r, 0, 0), (r * 0.7, r * 0.7, 0), (0, r, 0), (-r * 0.7, r * 0.7, 0), + (-r, 0, 0), (-r * 0.7, -r * 0.7, 0), (0, -r, 0), (r * 0.7, -r * 0.7, 0) + ] + ring_3d = [(self.x + lx, self.y + ly, self.z) for lx, ly, lz in ring_local] + + # Cúpula superior e inferior + top = (self.x, self.y, self.z + h) + bottom = (self.x, self.y, self.z - h) + + draw_vector_shape(surface, ring_3d, COLOR_YELLOW, p_x, p_y, p_angle, True) + for pt in ring_3d[::2]: + draw_vector_shape(surface, [pt, top], COLOR_YELLOW, p_x, p_y, p_angle, False) + draw_vector_shape(surface, [pt, bottom], COLOR_YELLOW, p_x, p_y, p_angle, False) + + +class EnemyTank: + + def __init__(self, x, y, enemy_type="RED"): + self.x = x + self.y = y + self.enemy_type = enemy_type + + # Atributos según el tipo de tanque + if enemy_type == "RED": + self.hp = 1 + self.color = COLOR_RED + self.score_value = 1000 + elif enemy_type == "BLUE": + self.hp = 2 + self.color = COLOR_BLUE + self.score_value = 2500 + elif enemy_type == "PURPLE": + self.hp = 3 + self.color = COLOR_PURPLE + self.score_value = 5000 + + self.angle = random.uniform(0, math.pi * 2) + self.turret_angle = self.angle + self.speed = 0.03 + self.shoot_cooldown = random.randint(120, 240) + self.alive = True + self.size = 2.5 + self.radius = 1.5 + + def update(self, player_x, player_y, bullets_list, obstacles, enemies, player_radius): + dx = player_x - self.x + dy = player_y - self.y + target_angle = math.atan2(dx, dy) + + diff_turret = target_angle - self.turret_angle + diff_turret = (diff_turret + math.pi) % (2 * math.pi) - math.pi + self.turret_angle += max(-0.03, min(0.03, diff_turret)) + + diff_body = target_angle - self.angle + diff_body = (diff_body + math.pi) % (2 * math.pi) - math.pi + self.angle += max(-0.015, min(0.015, diff_body)) + + dist = math.hypot(dx, dy) + if dist > 6.0: + next_x = self.x + math.sin(self.angle) * self.speed + next_y = self.y + math.cos(self.angle) * self.speed + + # Comprobar colisiones con obstáculos + can_move = True + for obstacle in obstacles: + min_dist = obstacle.radius + self.radius + if math.hypot(next_x - obstacle.x, next_y - obstacle.y) < min_dist: + can_move = False + break + + # Comprobar colisiones con otros tanques enemigos + if can_move: + for other in enemies: + if other is not self and other.alive: + min_dist = other.radius + self.radius + if math.hypot(next_x - other.x, next_y - other.y) < min_dist: + can_move = False + break + + # Comprobar colisión con el tanque del jugador + if can_move: + min_dist = player_radius + self.radius + if math.hypot(next_x - player_x, next_y - player_y) < min_dist: + can_move = False + + if can_move: + self.x = next_x + self.y = next_y + + if self.shoot_cooldown > 0: + self.shoot_cooldown -= 1 + else: + if dist < 40.0: + bullets_list.append( + Bullet(self.x, self.y, self.turret_angle, is_player=False) + ) + SOUND_SHOOT.play() + self.shoot_cooldown = random.randint(180, 300) + + def draw(self, surface, p_x, p_y, p_angle): + cos_b = math.cos(self.angle) + sin_b = math.sin(self.angle) + + def transform_body(px, py, pz): + rx = px * cos_b - py * sin_b + ry = px * sin_b + py * cos_b + return (self.x + rx, self.y + ry, pz) + + cos_t = math.cos(self.turret_angle) + sin_t = math.sin(self.turret_angle) + + def transform_turret(px, py, pz): + rx = px * cos_t - py * sin_t + ry = px * sin_t + py * cos_t + return (self.x + rx, self.y + ry, pz) + + body_local = [ + (-1.2, -1.5, 0), (1.2, -1.5, 0), + (1.2, 1.5, 0), (-1.2, 1.5, 0), + (-1.0, -1.2, 0.8), (1.0, -1.2, 0.8), + (1.0, 1.2, 0.8), (-1.0, 1.2, 0.8) + ] + body_3d = [transform_body(*pt) for pt in body_local] + + turret_local = [ + (-0.6, -0.6, 0.8), (0.6, -0.6, 0.8), + (0.6, 0.6, 0.8), (-0.6, 0.6, 0.8), + (-0.4, -0.4, 1.4), (0.4, -0.4, 1.4), + (0.4, 0.4, 1.4), (-0.4, 0.4, 1.4) + ] + turret_3d = [transform_turret(*pt) for pt in turret_local] + + cannon_3d = [transform_turret(0, 0.4, 1.1), transform_turret(0, 2.6, 1.1)] + + draw_vector_shape(surface, body_3d[:4], self.color, p_x, p_y, p_angle) + draw_vector_shape(surface, body_3d[4:], self.color, p_x, p_y, p_angle) + for i in range(4): + draw_vector_shape( + surface, [body_3d[i], body_3d[i + 4]], self.color, p_x, p_y, p_angle, False + ) + + draw_vector_shape(surface, turret_3d[4:], self.color, p_x, p_y, p_angle) + for i in range(4): + draw_vector_shape( + surface, [turret_3d[i], turret_3d[i + 4]], self.color, p_x, p_y, p_angle, False + ) + + draw_vector_shape(surface, cannon_3d, self.color, p_x, p_y, p_angle, False) + + +class Bullet: + + def __init__(self, x, y, angle, is_player=True): + self.x = x + self.y = y + self.z = 0.8 + self.angle = angle + self.speed = 1.2 + self.max_distance = 80.0 + self.traveled = 0.0 + self.is_player = is_player + self.alive = True + + def update(self): + self.x += math.sin(self.angle) * self.speed + self.y += math.cos(self.angle) * self.speed + self.traveled += self.speed + if self.traveled >= self.max_distance: + self.alive = False + + def draw(self, surface, p_x, p_y, p_angle): + length = 1.8 + front_x = self.x + math.sin(self.angle) * length + front_y = self.y + math.cos(self.angle) * length + + p_back = project_3d(self.x, self.y, self.z, p_x, p_y, p_angle) + p_front = project_3d(front_x, front_y, self.z, p_x, p_y, p_angle) + + color = COLOR_WHITE if self.is_player else COLOR_RED + + if p_back and p_front: + pygame.draw.line(surface, color, p_back, p_front, 2) + + +class Game: + + def __init__(self): + self.player_x = 0.0 + self.player_y = 0.0 + self.player_angle = 0.0 + self.player_radius = 1.5 + self.move_speed = 0.12 + self.rot_speed = 0.03 + self.shoot_cooldown = 0 + self.flash_timer = 0 + self.shield = 10 # Escudo / Vidas del jugador + + self.score = 0 + self.kills = 0 # Contador de tanques destruidos + self.initial_enemies = 2 + self.spawn_delay = 0 # Temporizador para controlar la velocidad de reaparición + self.ufo = None # Instancia del OVNI / Platillo volante + self.ufo_spawn_timer = random.randint(600, 1200) # Cada 10-20 segundos + self.bullets = [] + self.powerups = [] + self.enemies = [] + + # Obstáculos + self.obstacles = [ + Pyramid(-15, 15), + Pyramid(20, 10), + Pyramid(-10, 40), + Pyramid(15, 35), + Pyramid(0, 50), + ] + self.font = pygame.font.SysFont("monospace", 15, bold=True) + self.font_title = pygame.font.SysFont("monospace", 28, bold=True) + self.game_over = False + self.in_start_screen = True # Estado para la pantalla de inicio + + # Generar enemigos iniciales + for _ in range(self.initial_enemies): + self.spawn_enemy() + + def draw_start_screen(self): + """Muestra las instrucciones del juego con iconos vectoriales en la pantalla inicial.""" + SCREEN.fill(COLOR_BG) + + title_surf = self.font_title.render("TANK BATTLEZONE", True, COLOR_GREEN) + SCREEN.blit(title_surf, (WIDTH // 2 - title_surf.get_width() // 2, 25)) + + y_offset = 70 + lines_ctrl = [ + ("CONTROLES:", COLOR_YELLOW), + (" [ARRIBA/W][ABAJO/S] : Avanzar / Retroceder", COLOR_WHITE), + (" [IZQ/A][DER/D] : Girar tanque", COLOR_WHITE), + (" [ESPACIO] : Disparar", COLOR_WHITE), + ] + + for text, color in lines_ctrl: + line_surf = self.font.render(text, True, color) + SCREEN.blit(line_surf, (40, y_offset)) + y_offset += 20 + + y_offset += 10 + hdr_surf = self.font.render("ELEMENTOS DEL JUEGO:", True, COLOR_YELLOW) + SCREEN.blit(hdr_surf, (40, y_offset)) + y_offset += 28 + + # Lista de items con sus respectivos dibujadores de iconos + items = [ + (draw_pyramid_icon, "PIRAMIDE : Obstaculo infranqueable", COLOR_DARK_GREEN), + (lambda s, cx, cy: draw_tank_icon(s, cx, cy, COLOR_RED), "TANQUE ROJO : 1 impacto (1000 Pts)", COLOR_RED), + (lambda s, cx, cy: draw_tank_icon(s, cx, cy, COLOR_BLUE), "TANQUE AZUL : 2 impactos (2500 Pts)", COLOR_BLUE), + (lambda s, cx, cy: draw_tank_icon(s, cx, cy, COLOR_PURPLE), "TANQUE PURPURA : 3 impactos (5000 Pts) - Suelta escudo", COLOR_PURPLE), + (draw_diamond_icon, "DIAMANTE AMARILLO : Escudo (+1 SHIELD / 500 Pts)", COLOR_YELLOW), + (draw_ufo_icon, "OVNI AMARILLO : Cruzador - ¡Rellena ESCUDO MAX! (3000 Pts)", COLOR_YELLOW), + ] + + icon_x = 60 + text_x = 95 + + for draw_fn, text, color in items: + draw_fn(SCREEN, icon_x, y_offset + 10) + line_surf = self.font.render(text, True, color) + SCREEN.blit(line_surf, (text_x, y_offset)) + y_offset += 38 + + y_offset += 15 + start_surf = self.font.render("PRESIONA INTRO PARA COMENZAR", True, COLOR_GREEN) + SCREEN.blit(start_surf, (WIDTH // 2 - start_surf.get_width() // 2, y_offset)) + + pygame.display.flip() + + def get_next_enemy_type(self): + """Calcula el tipo de enemigo a generar según la progresión de bajas.""" + if self.kills >= 25: + return "PURPLE" + + prob_purple = min(0.8, self.kills / 25.0) + prob_blue = min(0.6, self.kills / 15.0) + + r = random.random() + if r < prob_purple: + return "PURPLE" + elif r < prob_purple + prob_blue: + return "BLUE" + else: + return "RED" + + def spawn_enemy(self): + """Genera un nuevo enemigo a una distancia razonable del jugador sin solaparse con obstáculos.""" + e_type = self.get_next_enemy_type() + for _ in range(50): + spawn_angle = random.uniform(0, math.pi * 2) + spawn_dist = random.uniform(25, 45) + new_x = self.player_x + math.sin(spawn_angle) * spawn_dist + new_y = self.player_y + math.cos(spawn_angle) * spawn_dist + + # Verificar que no aparezca dentro de un obstáculo + valid = True + for obstacle in self.obstacles: + if math.hypot(new_x - obstacle.x, new_y - obstacle.y) < obstacle.radius + 2.0: + valid = False + break + if valid: + self.enemies.append(EnemyTank(new_x, new_y, enemy_type=e_type)) + break + + def shoot(self): + if self.shoot_cooldown <= 0 and not self.game_over and not self.in_start_screen: + start_x = self.player_x + math.sin(self.player_angle) * 1.0 + start_y = self.player_y + math.cos(self.player_angle) * 1.0 + self.bullets.append(Bullet(start_x, start_y, self.player_angle, is_player=True)) + self.shoot_cooldown = 15 + self.flash_timer = 3 + SOUND_SHOOT.play() + + def draw_mountains(self): + mountain_profile = [ + 0, 15, 30, 10, 0, 25, 45, 15, 0, 10, 20, 5, 0, 35, 15, 0 + ] + step = WIDTH / (len(mountain_profile) - 1) + angle_offset = (math.degrees(self.player_angle) * 4) % WIDTH + + pts = [] + for i, h in enumerate(mountain_profile): + x = (i * step - angle_offset) % WIDTH + y = HORIZON_Y - h + pts.append((x, y)) + + sorted_pts = sorted(pts, key=lambda p: p[0]) + for i in range(len(sorted_pts) - 1): + if abs(sorted_pts[i][0] - sorted_pts[i + 1][0]) < step * 2: + pygame.draw.line( + SCREEN, COLOR_DARK_GREEN, sorted_pts[i], sorted_pts[i + 1], 1 + ) + + def draw_radar(self): + radar_cx, radar_cy, radar_r = WIDTH // 2, 45, 35 + pygame.draw.circle(SCREEN, COLOR_BG, (radar_cx, radar_cy), radar_r) + pygame.draw.circle(SCREEN, COLOR_GREEN, (radar_cx, radar_cy), radar_r, 1) + + fov_rad = math.radians(30) + pygame.draw.line( + SCREEN, + COLOR_DARK_GREEN, + (radar_cx, radar_cy), + ( + radar_cx + int(radar_r * math.sin(-fov_rad)), + radar_cy - int(radar_r * math.cos(-fov_rad)), + ), + ) + pygame.draw.line( + SCREEN, + COLOR_DARK_GREEN, + (radar_cx, radar_cy), + ( + radar_cx + int(radar_r * math.sin(fov_rad)), + radar_cy - int(radar_r * math.cos(fov_rad)), + ), + ) + + for enemy in self.enemies: + if not enemy.alive: + continue + dx = enemy.x - self.player_x + dy = enemy.y - self.player_y + + cos_a = math.cos(self.player_angle) + sin_a = math.sin(self.player_angle) + rx = dx * cos_a - dy * sin_a + ry = dx * sin_a + dy * cos_a + + dist = math.hypot(rx, ry) + if dist < 60.0: + blip_x = radar_cx + int((rx / 60.0) * radar_r) + blip_y = radar_cy - int((ry / 60.0) * radar_r) + if math.hypot(blip_x - radar_cx, blip_y - radar_cy) <= radar_r - 2: + pygame.draw.circle(SCREEN, enemy.color, (blip_x, blip_y), 2) + + # Mostrar OVNI en el radar + if self.ufo and self.ufo.alive: + dx = self.ufo.x - self.player_x + dy = self.ufo.y - self.player_y + cos_a = math.cos(self.player_angle) + sin_a = math.sin(self.player_angle) + rx = dx * cos_a - dy * sin_a + ry = dx * sin_a + dy * cos_a + + dist = math.hypot(rx, ry) + if dist < 60.0: + blip_x = radar_cx + int((rx / 60.0) * radar_r) + blip_y = radar_cy - int((ry / 60.0) * radar_r) + if math.hypot(blip_x - radar_cx, blip_y - radar_cy) <= radar_r - 2: + pygame.draw.circle(SCREEN, COLOR_YELLOW, (blip_x, blip_y), 3) + + def draw_hud(self): + pygame.draw.line( + SCREEN, COLOR_DARK_GREEN, (0, HORIZON_Y), (WIDTH, HORIZON_Y), 1 + ) + + cx, cy = WIDTH // 2, HORIZON_Y + + if self.flash_timer > 0: + pygame.draw.circle(SCREEN, COLOR_WHITE, (cx, cy), 12, 1) + self.flash_timer -= 1 + + pygame.draw.line(SCREEN, COLOR_GREEN, (cx - 15, cy), (cx - 5, cy), 1) + pygame.draw.line(SCREEN, COLOR_GREEN, (cx + 5, cy), (cx + 15, cy), 1) + pygame.draw.line(SCREEN, COLOR_GREEN, (cx, cy - 15), (cx, cy - 5), 1) + pygame.draw.line(SCREEN, COLOR_GREEN, (cx, cy + 5), (cx, cy + 15), 1) + + score_surf = self.font.render(f"SCORE: {self.score:06d}", True, COLOR_GREEN) + kills_surf = self.font.render(f"KILLS: {self.kills:03d}", True, COLOR_GREEN) + shield_surf = self.font.render(f"SHIELD: {'I ' * self.shield}", True, COLOR_YELLOW) + + SCREEN.blit(score_surf, (20, 20)) + SCREEN.blit(kills_surf, (20, 40)) + SCREEN.blit(shield_surf, (20, 60)) + + if self.game_over: + go_surf = self.font_title.render("GAME OVER", True, COLOR_RED) + score_final_surf = self.font.render(f"PUNTUACION FINAL: {self.score:06d}", True, COLOR_WHITE) + kills_final_surf = self.font.render(f"TANQUES DESTRUIDOS: {self.kills:03d}", True, COLOR_WHITE) + restart_surf = self.font.render("PRESIONA R PARA VOLVER A JUGAR", True, COLOR_YELLOW) + + SCREEN.blit(go_surf, (WIDTH // 2 - go_surf.get_width() // 2, HEIGHT // 2 - 60)) + SCREEN.blit(score_final_surf, (WIDTH // 2 - score_final_surf.get_width() // 2, HEIGHT // 2 - 10)) + SCREEN.blit(kills_final_surf, (WIDTH // 2 - kills_final_surf.get_width() // 2, HEIGHT // 2 + 15)) + SCREEN.blit(restart_surf, (WIDTH // 2 - restart_surf.get_width() // 2, HEIGHT // 2 + 50)) + + self.draw_radar() + + def reset_game(self): + self.player_x = 0.0 + self.player_y = 0.0 + self.player_angle = 0.0 + self.shield = 10 + self.score = 0 + self.kills = 0 + self.spawn_delay = 0 + self.ufo = None + self.ufo_spawn_timer = random.randint(600, 1200) + self.bullets.clear() + self.powerups.clear() + self.enemies.clear() + self.game_over = False + for _ in range(self.initial_enemies): + self.spawn_enemy() + + def try_move_player(self, dx, dy): + """Intenta mover al jugador comprobando colisiones físicas con obstáculos y tanques enemigos.""" + next_x = self.player_x + dx + next_y = self.player_y + dy + + # Colisión con obstáculos (Pirámides) + for obstacle in self.obstacles: + min_dist = obstacle.radius + self.player_radius + if math.hypot(next_x - obstacle.x, next_y - obstacle.y) < min_dist: + SOUND_CLINK.play() + return + + # Colisión con tanques enemigos + for enemy in self.enemies: + if enemy.alive: + min_dist = enemy.radius + self.player_radius + if math.hypot(next_x - enemy.x, next_y - enemy.y) < min_dist: + SOUND_CLINK.play() + return + + self.player_x = next_x + self.player_y = next_y + + def run(self): + running = True + while running: + CLOCK.tick(FPS) + + # Control de la pantalla de inicio + if self.in_start_screen: + self.draw_start_screen() + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_RETURN: + self.in_start_screen = False + continue + + if self.shoot_cooldown > 0: + self.shoot_cooldown -= 1 + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_SPACE: + self.shoot() + elif event.key == pygame.K_r and self.game_over: + self.reset_game() + + keys = pygame.key.get_pressed() + + # Movimiento y Control + if not self.game_over: + moving_fwd_back = keys[pygame.K_UP] or keys[pygame.K_w] or keys[pygame.K_DOWN] or keys[pygame.K_s] + turning = keys[pygame.K_LEFT] or keys[pygame.K_a] or keys[pygame.K_RIGHT] or keys[pygame.K_d] + + if keys[pygame.K_UP] or keys[pygame.K_w]: + dx = math.sin(self.player_angle) * self.move_speed + dy = math.cos(self.player_angle) * self.move_speed + self.try_move_player(dx, dy) + if keys[pygame.K_DOWN] or keys[pygame.K_s]: + dx = -math.sin(self.player_angle) * self.move_speed + dy = -math.cos(self.player_angle) * self.move_speed + self.try_move_player(dx, dy) + + if keys[pygame.K_LEFT] or keys[pygame.K_a]: + self.player_angle -= self.rot_speed + if keys[pygame.K_RIGHT] or keys[pygame.K_d]: + self.player_angle += self.rot_speed + + if moving_fwd_back or turning: + if not CHANNEL_MOVEMENT.get_busy(): + CHANNEL_MOVEMENT.play(SOUND_MOVEMENT, loops=-1) + else: + CHANNEL_MOVEMENT.stop() + else: + CHANNEL_MOVEMENT.stop() + + # Gestión de reaparición progresiva de enemigos + if not self.game_over: + max_enemies = min(6, self.initial_enemies + (self.kills // 8)) + if len(self.enemies) < max_enemies: + if self.spawn_delay <= 0: + self.spawn_enemy() + self.spawn_delay = 180 + else: + self.spawn_delay -= 1 + + # Temporizador de aparición del OVNI + if self.ufo is None or not self.ufo.alive: + if self.ufo_spawn_timer <= 0: + self.ufo = UFO(self.player_x, self.player_y, self.player_angle) + self.ufo_spawn_timer = random.randint(900, 1800) # 15-30 segundos para la siguiente aparición + else: + self.ufo_spawn_timer -= 1 + else: + self.ufo.update() + + # Actualización de Powerups + for pw in self.powerups[:]: + pw.update() + if math.hypot(pw.x - self.player_x, pw.y - self.player_y) < pw.radius + 0.5: + if self.shield < 10: + self.shield = min(10, self.shield + 1) + self.score += 500 + SOUND_POWERUP.play() + self.powerups.remove(pw) + + # Actualización y Colisiones de Proyectiles + for bullet in self.bullets[:]: + bullet.update() + if not bullet.alive: + self.bullets.remove(bullet) + continue + + # Colisión con obstáculos (Pirámides) + hit_obstacle = False + for obstacle in self.obstacles: + if math.hypot(bullet.x - obstacle.x, bullet.y - obstacle.y) < obstacle.radius: + bullet.alive = False + hit_obstacle = True + SOUND_CLINK.play() + break + + if hit_obstacle: + continue + + # Colisión con el OVNI + if bullet.is_player and self.ufo and self.ufo.alive: + if math.hypot(bullet.x - self.ufo.x, bullet.y - self.ufo.y) < self.ufo.radius: + bullet.alive = False + self.ufo.alive = False + self.score += self.ufo.score_value + self.shield = 10 # Recarga el escudo al máximo (10) + SOUND_POWERUP.play() + SOUND_EXPLOSION.play() + continue + + # Colisión con el jugador + if not bullet.is_player and not self.game_over: + if math.hypot(bullet.x - self.player_x, bullet.y - self.player_y) < 1.5: + bullet.alive = False + self.shield -= 1 + SOUND_HIT_PLAYER.play() + if self.shield <= 0: + self.game_over = True + continue + + # Colisión con enemigo + if bullet.is_player: + for enemy in self.enemies: + if ( + enemy.alive + and math.hypot(bullet.x - enemy.x, bullet.y - enemy.y) + < enemy.size + ): + bullet.alive = False + enemy.hp -= 1 + SOUND_CLINK.play() + + if enemy.hp <= 0: + enemy.alive = False + self.score += enemy.score_value + self.kills += 1 + SOUND_EXPLOSION.play() + + if enemy.enemy_type == "PURPLE" and random.random() < 0.5: + self.powerups.append(ShieldPowerup(enemy.x, enemy.y)) + + self.enemies = [e for e in self.enemies if e.alive] + self.spawn_delay = 180 + break + + if not self.game_over: + for enemy in self.enemies: + if enemy.alive: + enemy.update( + self.player_x, + self.player_y, + self.bullets, + self.obstacles, + self.enemies, + self.player_radius, + ) + + SCREEN.fill(COLOR_BG) + + self.draw_mountains() + + for obstacle in self.obstacles: + obstacle.draw(SCREEN, self.player_x, self.player_y, self.player_angle) + + for pw in self.powerups: + pw.draw(SCREEN, self.player_x, self.player_y, self.player_angle) + + if self.ufo and self.ufo.alive: + self.ufo.draw(SCREEN, self.player_x, self.player_y, self.player_angle) + + for enemy in self.enemies: + if enemy.alive: + enemy.draw(SCREEN, self.player_x, self.player_y, self.player_angle) + + for bullet in self.bullets: + bullet.draw(SCREEN, self.player_x, self.player_y, self.player_angle) + + self.draw_hud() + pygame.display.flip() + + pygame.quit() + sys.exit() + + +if __name__ == "__main__": + Game().run()