Home / Lenguajes / Crea un juego de disparos arcade con Python y Pygame paso a paso
Crea un juego de disparos arcade con Python y Pygame paso a paso

Crea un juego de disparos arcade con Python y Pygame paso a paso

23 October 2026 · 18 min de lectura ··

De la feria a la pantalla: una galería de tiro en Python

Las galerías de tiro de las ferias son uno de los juegos más sencillos y adictivos que existen: filas de patos o dianas que se desplazan de un lado a otro, un rifle y un contador de puntos. Su mecánica se trasladó muy pronto a las máquinas recreativas y, por su simplicidad, es hoy un proyecto ideal para aprender a programar videojuegos.

En esta guía vamos a construir una galería de tiro estilo arcade con Python y Pygame. El juego tendrá un arma que gira siguiendo el ratón, filas de objetivos que se mueven a distintas velocidades según su nivel, detección de impactos, marcador de puntos, tiempo y disparos, tres modos de juego, menú principal, pausa, pantalla de fin de partida, récords guardados en un archivo y efectos de sonido.

Por el camino aprenderemos los conceptos fundamentales de cualquier videojuego: el bucle principal, el movimiento independiente de la tasa de fotogramas, la rotación de sprites, las colisiones, la gestión de estados y la persistencia de datos. Son ideas que se aplican igual en motores profesionales como Godot o Unity.

Pygame y pygame-ce: qué biblioteca usar

Pygame es la biblioteca más conocida para crear juegos en 2D con Python. Se apoya en SDL, una biblioteca multiplataforma escrita en C, y ofrece funciones para dibujar en pantalla, cargar imágenes y sonidos, leer el teclado y el ratón y controlar el tiempo.

Desde 2023 existe además pygame-ce, la edición comunitaria, una bifurcación mantenida por gran parte de los antiguos colaboradores del proyecto. Publica versiones con más frecuencia, incorpora mejoras de rendimiento y nuevas funciones, y mantiene la compatibilidad con el código escrito para Pygame. Su rama actual es la 2.5. Para proyectos nuevos es la opción recomendada, y es la que usaremos.

Ambos paquetes se importan con el mismo nombre, pygame, así que no deben instalarse a la vez. Crea un entorno virtual e instala la biblioteca:

mkdir galeria-tiro && cd galeria-tiro
python3 -m venv .venv
source .venv/bin/activate        # En Windows: .venv\Scripts\activate
python -m pip install pygame-ce

Comprueba la instalación:

python -c "import pygame; print(pygame.version.ver)"

Estructura del proyecto

Organizaremos los archivos así:

galeria-tiro
├── main.py
├── config.py
├── entidades.py
├── records.py
├── assets
│   ├── imagenes
│   │   ├── fondo.png
│   │   ├── arma.png
│   │   ├── diana_1.png
│   │   ├── diana_2.png
│   │   └── diana_3.png
│   ├── sonidos
│   │   ├── disparo.wav
│   │   ├── impacto.wav
│   │   └── musica.ogg
│   └── fuentes
│       └── arcade.ttf
└── records.txt

Separar la configuración, las entidades del juego y la gestión de récords en módulos distintos hace que el código sea más fácil de leer y modificar. Las imágenes y sonidos pueden ser tuyos o de bancos de recursos libres, como los de Kenney u OpenGameArt; revisa siempre su licencia. Si todavía no tienes recursos gráficos, el código que veremos dibuja figuras geométricas como alternativa, de modo que el juego funciona desde el primer momento.

La configuración del juego

Reunir las constantes en un único archivo permite ajustar la dificultad o la resolución sin buscar números repartidos por el código:

# config.py
from pathlib import Path

ANCHO, ALTO = 960, 640
FPS = 60
TITULO = "Galería de tiro"

RAIZ = Path(__file__).parent
IMAGENES = RAIZ / "assets" / "imagenes"
SONIDOS = RAIZ / "assets" / "sonidos"
ARCHIVO_RECORDS = RAIZ / "records.txt"

# Cada nivel define cuántas filas hay, cuántas dianas por fila,
# su velocidad en píxeles por segundo y los puntos que valen.
NIVELES = [
    {"filas": [(4, 120, 10), (3, 170, 20)]},
    {"filas": [(5, 150, 10), (4, 210, 20), (3, 260, 30)]},
    {"filas": [(6, 180, 10), (5, 240, 20), (4, 300, 30), (3, 360, 50)]},
]

ALTURAS_FILAS = [120, 210, 300, 390]
TIEMPO_CONTRARRELOJ = 30
MUNICION_LIMITADA = 40

BLANCO = (245, 245, 245)
NEGRO = (20, 20, 25)
AMARILLO = (250, 200, 60)
ROJO = (220, 70, 60)

Cada fila se describe con una tupla de tres valores: número de dianas, velocidad y puntos. Las filas superiores, más alejadas visualmente, son más rápidas y valen más. Ajustar el equilibrio del juego se reduce a editar esta lista.

El bucle principal del juego

Todo videojuego se basa en un bucle que se repite decenas de veces por segundo. En cada vuelta hace tres cosas: procesar la entrada del jugador, actualizar el estado del mundo y dibujar el resultado. Entender el bucle principal del juego es el primer paso para programar cualquier título.

Este es el esqueleto mínimo con Pygame:

import pygame

pygame.init()
pantalla = pygame.display.set_mode((960, 640))
reloj = pygame.time.Clock()
activo = True

while activo:
    dt = reloj.tick(60) / 1000   # segundos desde el fotograma anterior

    for evento in pygame.event.get():
        if evento.type == pygame.QUIT:
            activo = False

    # actualizar(dt)

    pantalla.fill((20, 20, 25))
    # dibujar(pantalla)
    pygame.display.flip()

pygame.quit()

reloj.tick(60) limita el juego a 60 fotogramas por segundo y devuelve los milisegundos transcurridos desde la llamada anterior. Lo dividimos entre mil para obtener dt, el tiempo delta en segundos, que usaremos para mover los objetos.

pygame.event.get() vacía la cola de eventos: pulsaciones de teclas, clics, movimiento del ratón o el cierre de la ventana. Es imprescindible llamarla en cada fotograma; si no, el sistema operativo considerará que la aplicación no responde.

pygame.display.flip() muestra en pantalla todo lo dibujado durante el fotograma. Pygame dibuja primero en un búfer oculto y lo presenta de golpe, lo que evita parpadeos.

Movimiento independiente de los fotogramas

Un error muy común en los primeros juegos es mover los objetos un número fijo de píxeles por fotograma. En un ordenador potente, que genera más fotogramas, los objetos se moverán más rápido que en uno lento.

La solución es expresar las velocidades en píxeles por segundo y multiplicarlas por dt. Así se consigue un movimiento independiente de la tasa de fotogramas, idéntico en cualquier equipo:

# Incorrecto: depende de los FPS
diana.x += 3

# Correcto: 180 píxeles por segundo en cualquier equipo
diana.x += 180 * dt

Como las posiciones pasan a tener decimales, conviene guardarlas en variables float y convertirlas a enteros solo al dibujar. Si se guardan directamente en un pygame.Rect, que solo admite enteros, los movimientos lentos se pierden por redondeo. pygame-ce ofrece además pygame.FRect, una versión del rectángulo con coordenadas decimales.

Cargar imágenes con alternativa

Cargar imágenes es sencillo con pygame.image.load. Es importante llamar a convert_alpha() después, para convertir la imagen al formato de la pantalla; sin ello, el dibujado puede ser varias veces más lento. Para que el juego funcione aunque falte algún archivo, crearemos una función que dibuja una figura de sustitución:

# entidades.py
import math
import random

import pygame

from config import ALTURAS_FILAS, ANCHO, AMARILLO, IMAGENES, ROJO, BLANCO


def cargar_imagen(nombre, tamano, color):
    ruta = IMAGENES / nombre
    try:
        imagen = pygame.image.load(ruta).convert_alpha()
        return pygame.transform.smoothscale(imagen, tamano)
    except (FileNotFoundError, pygame.error):
        superficie = pygame.Surface(tamano, pygame.SRCALPHA)
        centro = (tamano[0] // 2, tamano[1] // 2)
        radio = min(tamano) // 2
        pygame.draw.circle(superficie, color, centro, radio)
        pygame.draw.circle(superficie, BLANCO, centro, radio * 2 // 3)
        pygame.draw.circle(superficie, color, centro, radio // 3)
        return superficie

La bandera pygame.SRCALPHA crea una superficie con canal de transparencia, de modo que solo se ve el círculo y no un cuadrado de fondo.

Las dianas y su generación procedural

Cada diana tiene una posición, una velocidad, una puntuación y un radio para detectar los impactos. Se moverán horizontalmente y reaparecerán por el lado opuesto al salir de la pantalla:

class Diana:
    def __init__(self, x, y, velocidad, puntos, imagen):
        self.x = float(x)
        self.y = float(y)
        self.velocidad = velocidad
        self.puntos = puntos
        self.imagen = imagen
        self.radio = imagen.get_width() // 2
        self.viva = True

    def actualizar(self, dt):
        self.x += self.velocidad * dt
        margen = self.radio
        if self.velocidad > 0 and self.x - margen > ANCHO:
            self.x = -margen
        elif self.velocidad < 0 and self.x + margen < 0:
            self.x = ANCHO + margen

    def contiene(self, punto):
        return math.hypot(punto[0] - self.x, punto[1] - self.y) <= self.radio

    def dibujar(self, pantalla):
        rect = self.imagen.get_rect(center=(round(self.x), round(self.y)))
        pantalla.blit(self.imagen, rect)

Para que cada partida sea distinta, las posiciones iniciales se generan de forma procedural. Repartimos las dianas de cada fila de manera uniforme a lo largo del ancho, con un pequeño desplazamiento aleatorio, y alternamos el sentido de las filas para que unas vayan a la derecha y otras a la izquierda:

COLORES_FILAS = [ROJO, AMARILLO, (90, 170, 240), (150, 220, 120)]


def generar_dianas(nivel_config):
    dianas = []
    for indice, (cantidad, velocidad, puntos) in enumerate(nivel_config["filas"]):
        y = ALTURAS_FILAS[indice]
        tamano = 70 - indice * 10
        imagen = cargar_imagen(f"diana_{min(indice + 1, 3)}.png",
                               (tamano, tamano), COLORES_FILAS[indice])
        sentido = 1 if indice % 2 == 0 else -1
        separacion = ANCHO / cantidad
        for n in range(cantidad):
            x = n * separacion + random.uniform(0, separacion * 0.4)
            dianas.append(Diana(x, y, velocidad * sentido, puntos, imagen))
    return dianas

Las filas superiores usan dianas más pequeñas, lo que, junto con su mayor velocidad, las hace más difíciles y justifica su mayor puntuación.

Rotar el arma hacia el ratón

El arma se sitúa en la parte inferior central de la pantalla y debe apuntar siempre hacia el cursor. Para rotar el arma hacia el ratón calculamos el ángulo entre ambos puntos con math.atan2, que devuelve el ángulo correcto en los cuatro cuadrantes:

class Arma:
    def __init__(self, imagen, posicion):
        self.original = imagen
        self.posicion = posicion
        self.imagen = imagen
        self.rect = imagen.get_rect(center=posicion)

    def actualizar(self, raton):
        dx = raton[0] - self.posicion[0]
        dy = self.posicion[1] - raton[1]      # el eje y de la pantalla crece hacia abajo
        angulo = math.degrees(math.atan2(dy, dx))
        angulo = max(10, min(170, angulo))    # no apuntar hacia el suelo
        self.imagen = pygame.transform.rotate(self.original, angulo - 90)
        self.rect = self.imagen.get_rect(center=self.posicion)

    def dibujar(self, pantalla):
        pantalla.blit(self.imagen, self.rect)

Hay tres detalles importantes. El primero es que el eje vertical de la pantalla crece hacia abajo, al contrario que en matemáticas, por eso invertimos la resta en dy. El segundo es que pygame.transform.rotate gira en sentido antihorario y supone que la imagen original apunta a la derecha; como nuestra arma apunta hacia arriba, restamos 90 grados.

El tercero, y el que más problemas causa, es que siempre hay que rotar la imagen original, nunca la ya rotada. Cada rotación degrada ligeramente la imagen y aumenta su tamaño para contener las esquinas, de modo que encadenar rotaciones la deforma en pocos segundos. Por la misma razón, tras rotar hay que recalcular el rectángulo con el centro fijo, porque la imagen rotada es más grande que la original.

Detectar si el disparo acierta

Cada clic del ratón cuenta como un disparo. Para detectar si el disparo acierta recorremos las dianas en orden inverso, desde la que se dibuja encima, y comprobamos si el punto del clic está dentro de alguna:

def disparar(self, punto):
    self.disparos += 1
    self.sonidos.reproducir("disparo")
    for diana in reversed(self.dianas):
        if diana.viva and diana.contiene(punto):
            diana.viva = False
            self.puntuacion += diana.puntos
            self.aciertos += 1
            self.sonidos.reproducir("impacto")
            return True
    return False

Usamos la distancia al centro en lugar del rectángulo de la imagen porque las dianas son redondas: con un rectángulo, un clic en la esquina transparente contaría como impacto. Para objetos con formas irregulares, Pygame ofrece máscaras de colisión píxel a píxel con pygame.mask.from_surface.

Al acertar, solo un objetivo se elimina por disparo, gracias al return tras el primer impacto. Cuando no quedan dianas vivas, se pasa al siguiente nivel.

Modos de juego y estados

El juego tendrá tres modos: libre, en el que se juega hasta superar todos los niveles; contrarreloj, con un tiempo límite; y munición limitada, con un número fijo de disparos. Además, el programa pasa por varias pantallas: menú, partida, pausa y fin de partida.

Una forma limpia de organizarlo es una máquina de estados: una variable indica la pantalla actual y el bucle principal delega en la función correspondiente:

from enum import Enum, auto


class Estado(Enum):
    MENU = auto()
    JUGANDO = auto()
    PAUSA = auto()
    FIN = auto()


class Modo(Enum):
    LIBRE = "Libre"
    CONTRARRELOJ = "Contrarreloj"
    MUNICION = "Munición limitada"

Usar Enum en lugar de cadenas o números sueltos evita errores tipográficos y deja claro qué valores son válidos. Cada estado decide qué eventos atiende y qué dibuja: en el menú, los clics eligen el modo; en la partida, son disparos; en la pausa, la tecla Escape reanuda.

Puntuación máxima en un archivo

Para que los récords sobrevivan entre sesiones, guardaremos la puntuación máxima en un archivo de texto, con una línea por modo de juego. El módulo debe tolerar que el archivo no exista o esté dañado:

# records.py
from config import ARCHIVO_RECORDS


def cargar_records():
    records = {}
    try:
        for linea in ARCHIVO_RECORDS.read_text(encoding="utf-8").splitlines():
            modo, _, valor = linea.partition("=")
            if valor.strip().isdigit():
                records[modo.strip()] = int(valor)
    except FileNotFoundError:
        pass
    return records


def guardar_record(modo, puntuacion):
    records = cargar_records()
    if puntuacion <= records.get(modo, 0):
        return False
    records[modo] = puntuacion
    contenido = "\n".join(f"{m}={p}" for m, p in records.items())
    ARCHIVO_RECORDS.write_text(contenido + "\n", encoding="utf-8")
    return True

El archivo resultante es legible y fácil de inspeccionar:

Libre=640
Contrarreloj=410
Munición limitada=530

guardar_record devuelve True cuando se bate un récord, lo que permite mostrar un mensaje de celebración en la pantalla final. Para un juego distribuido públicamente sería preferible guardar el archivo en la carpeta de datos del usuario y no junto al código, y usar JSON si la información crece.

Música y efectos de sonido

El sonido marca una gran diferencia en la sensación de juego. Pygame distingue entre la música de fondo, que se reproduce en streaming con pygame.mixer.music, y los efectos cortos, que se cargan en memoria con pygame.mixer.Sound. Igual que con las imágenes, haremos que el juego funcione aunque falten archivos o no haya dispositivo de audio:

class Sonidos:
    def __init__(self, carpeta):
        self.efectos = {}
        try:
            pygame.mixer.init()
        except pygame.error:
            return
        for nombre in ("disparo", "impacto"):
            ruta = carpeta / f"{nombre}.wav"
            if ruta.exists():
                self.efectos[nombre] = pygame.mixer.Sound(ruta)
                self.efectos[nombre].set_volume(0.4)
        musica = carpeta / "musica.ogg"
        if musica.exists():
            pygame.mixer.music.load(musica)
            pygame.mixer.music.set_volume(0.3)
            pygame.mixer.music.play(-1)   # repetir indefinidamente

    def reproducir(self, nombre):
        efecto = self.efectos.get(nombre)
        if efecto:
            efecto.play()

Para los efectos es preferible el formato WAV u OGG; el MP3 funciona para la música en las versiones actuales, pero OGG es el más fiable en todas las plataformas.

El juego completo

Con todas las piezas, este es el módulo principal que las une. Gestiona los estados, el marcador, los niveles y las condiciones de fin de partida:

# main.py
import pygame

from config import (ALTO, ANCHO, AMARILLO, BLANCO, FPS, MUNICION_LIMITADA,
                    NEGRO, NIVELES, SONIDOS, TIEMPO_CONTRARRELOJ, TITULO)
from entidades import Arma, Estado, Modo, Sonidos, cargar_imagen, generar_dianas
from records import cargar_records, guardar_record


class Juego:
    def __init__(self):
        pygame.init()
        pygame.display.set_caption(TITULO)
        self.pantalla = pygame.display.set_mode((ANCHO, ALTO))
        self.reloj = pygame.time.Clock()
        self.fuente = pygame.font.Font(None, 36)
        self.fuente_grande = pygame.font.Font(None, 72)
        self.sonidos = Sonidos(SONIDOS)
        imagen_arma = cargar_imagen("arma.png", (40, 110), (120, 120, 130))
        self.arma = Arma(imagen_arma, (ANCHO // 2, ALTO - 50))
        self.estado = Estado.MENU
        self.modo = Modo.LIBRE
        self.records = cargar_records()
        self.nuevo_record = False

    def nueva_partida(self, modo):
        self.modo = modo
        self.nivel = 0
        self.puntuacion = 0
        self.disparos = 0
        self.aciertos = 0
        self.tiempo = 0.0
        self.dianas = generar_dianas(NIVELES[self.nivel])
        self.estado = Estado.JUGANDO

    def disparar(self, punto):
        self.disparos += 1
        self.sonidos.reproducir("disparo")
        for diana in reversed(self.dianas):
            if diana.viva and diana.contiene(punto):
                diana.viva = False
                self.puntuacion += diana.puntos
                self.aciertos += 1
                self.sonidos.reproducir("impacto")
                break

    def actualizar(self, dt):
        self.tiempo += dt
        self.arma.actualizar(pygame.mouse.get_pos())
        for diana in self.dianas:
            diana.actualizar(dt)

        if not any(d.viva for d in self.dianas):
            self.nivel += 1
            if self.nivel >= len(NIVELES):
                self.terminar()
                return
            self.dianas = generar_dianas(NIVELES[self.nivel])

        if self.modo is Modo.CONTRARRELOJ and self.tiempo >= TIEMPO_CONTRARRELOJ:
            self.terminar()
        if self.modo is Modo.MUNICION and self.disparos >= MUNICION_LIMITADA:
            self.terminar()

    def terminar(self):
        self.nuevo_record = guardar_record(self.modo.value, self.puntuacion)
        self.records = cargar_records()
        self.estado = Estado.FIN

    def texto(self, contenido, posicion, fuente=None, color=BLANCO, centrado=False):
        superficie = (fuente or self.fuente).render(contenido, True, color)
        rect = superficie.get_rect()
        if centrado:
            rect.center = posicion
        else:
            rect.topleft = posicion
        self.pantalla.blit(superficie, rect)
        return rect

    def dibujar_marcador(self):
        precision = self.aciertos / self.disparos * 100 if self.disparos else 0
        self.texto(f"Puntos: {self.puntuacion}", (20, 15))
        self.texto(f"Nivel: {self.nivel + 1}/{len(NIVELES)}", (20, 45))
        self.texto(f"Disparos: {self.disparos}  Precisión: {precision:.0f} %", (20, 75))
        if self.modo is Modo.CONTRARRELOJ:
            restante = max(0, TIEMPO_CONTRARRELOJ - self.tiempo)
            self.texto(f"Tiempo: {restante:4.1f}", (ANCHO - 200, 15), color=AMARILLO)
        elif self.modo is Modo.MUNICION:
            self.texto(f"Balas: {MUNICION_LIMITADA - self.disparos}", (ANCHO - 200, 15),
                       color=AMARILLO)
        else:
            self.texto(f"Tiempo: {self.tiempo:5.1f}", (ANCHO - 200, 15))

    def dibujar_menu(self):
        self.texto(TITULO, (ANCHO // 2, 140), self.fuente_grande, AMARILLO, True)
        self.botones = {}
        for i, modo in enumerate(Modo):
            record = self.records.get(modo.value, 0)
            etiqueta = f"{modo.value}  (récord: {record})"
            rect = self.texto(etiqueta, (ANCHO // 2, 280 + i * 70), centrado=True)
            self.botones[modo] = rect.inflate(40, 20)
            pygame.draw.rect(self.pantalla, BLANCO, self.botones[modo], 2, border_radius=8)

    def dibujar(self):
        self.pantalla.fill(NEGRO)
        if self.estado is Estado.MENU:
            self.dibujar_menu()
        else:
            for diana in self.dianas:
                if diana.viva:
                    diana.dibujar(self.pantalla)
            self.arma.dibujar(self.pantalla)
            self.dibujar_marcador()
        if self.estado is Estado.PAUSA:
            self.texto("PAUSA", (ANCHO // 2, ALTO // 2 - 40), self.fuente_grande,
                       AMARILLO, True)
            self.texto("Esc: continuar   M: menú", (ANCHO // 2, ALTO // 2 + 20),
                       centrado=True)
        elif self.estado is Estado.FIN:
            self.texto("FIN DE LA PARTIDA", (ANCHO // 2, ALTO // 2 - 60),
                       self.fuente_grande, AMARILLO, True)
            self.texto(f"Puntuación: {self.puntuacion}", (ANCHO // 2, ALTO // 2),
                       centrado=True)
            if self.nuevo_record:
                self.texto("¡Nuevo récord!", (ANCHO // 2, ALTO // 2 + 40),
                           color=AMARILLO, centrado=True)
            self.texto("Enter: menú", (ANCHO // 2, ALTO // 2 + 90), centrado=True)
        pygame.display.flip()

    def manejar_evento(self, evento):
        if self.estado is Estado.MENU and evento.type == pygame.MOUSEBUTTONDOWN:
            for modo, rect in self.botones.items():
                if rect.collidepoint(evento.pos):
                    self.nueva_partida(modo)
        elif self.estado is Estado.JUGANDO:
            if evento.type == pygame.MOUSEBUTTONDOWN and evento.button == 1:
                self.disparar(evento.pos)
            elif evento.type == pygame.KEYDOWN and evento.key == pygame.K_ESCAPE:
                self.estado = Estado.PAUSA
        elif self.estado is Estado.PAUSA and evento.type == pygame.KEYDOWN:
            if evento.key == pygame.K_ESCAPE:
                self.estado = Estado.JUGANDO
            elif evento.key == pygame.K_m:
                self.estado = Estado.MENU
        elif self.estado is Estado.FIN and evento.type == pygame.KEYDOWN:
            if evento.key == pygame.K_RETURN:
                self.estado = Estado.MENU

    def ejecutar(self):
        self.botones = {}
        activo = True
        while activo:
            dt = self.reloj.tick(FPS) / 1000
            for evento in pygame.event.get():
                if evento.type == pygame.QUIT:
                    activo = False
                else:
                    self.manejar_evento(evento)
            if self.estado is Estado.JUGANDO:
                self.actualizar(dt)
            self.dibujar()
        pygame.quit()


if __name__ == "__main__":
    Juego().ejecutar()

Para que este archivo funcione, añade al final de entidades.py las clases Estado, Modo y Sonidos que vimos en las secciones anteriores, junto con la importación de Enum y auto. Ejecuta el juego con:

python main.py

La pausa funciona de forma natural: como solo llamamos a actualizar cuando el estado es JUGANDO, el tiempo y las dianas se congelan, pero seguimos dibujando la escena de fondo. Al reiniciar desde el menú, nueva_partida vuelve a generar las dianas con nuevas coordenadas aleatorias y pone a cero todos los contadores.

Probar la lógica sin abrir una ventana

Parte del código, como los cálculos de ángulos o el sistema de récords, se puede probar sin ejecutar el juego. Separar la lógica del dibujado facilita escribir pruebas automáticas:

# test_logica.py
import math


def angulo_hacia(origen, destino):
    dx = destino[0] - origen[0]
    dy = origen[1] - destino[1]
    return max(10, min(170, math.degrees(math.atan2(dy, dx))))


def test_apuntar_arriba():
    assert angulo_hacia((480, 590), (480, 100)) == 90


def test_limite_inferior():
    assert angulo_hacia((480, 590), (900, 600)) == 10
python -m pip install pytest
python -m pytest -q
..                                                                       [100%]
2 passed in 0.01s

Rendimiento y buenas prácticas

Algunas recomendaciones que marcan la diferencia en un juego con Pygame:

  • Convierte siempre las imágenes con convert() o convert_alpha() tras cargarlas.
  • Carga los recursos una sola vez al inicio, nunca dentro del bucle principal.
  • Renderiza los textos que no cambian una vez y reutiliza la superficie; font.render es relativamente costoso.
  • Usa dt para todos los movimientos y temporizadores.
  • Agrupa los objetos en pygame.sprite.Group cuando su número crezca, para dibujarlos y actualizarlos de forma eficiente.
  • Si el juego crece, separa cada estado en su propia clase con métodos para manejar eventos, actualizar y dibujar.

Cuando quieras compartir el juego con personas que no tienen Python instalado, herramientas como PyInstaller generan un ejecutable para Windows, macOS o Linux, y pygbag permite compilarlo a WebAssembly para jugarlo en el navegador.

Errores frecuentes y cómo resolverlos

El primero es que la ventana se congele o el sistema diga que no responde. Casi siempre es porque no se llama a pygame.event.get() en cada fotograma, o porque hay un bucle largo que bloquea el principal.

El segundo es este error al cargar una imagen:

pygame.error: No video mode has been set

Se produce al llamar a convert_alpha() antes de crear la ventana con set_mode. Crea siempre la pantalla antes de cargar los recursos.

El tercero es que el arma se deforme o crezca sin control al rotar. Estás rotando la imagen ya rotada en lugar de la original.

El cuarto es que los objetos se muevan a distinta velocidad en distintos ordenadores. Falta multiplicar las velocidades por dt.

El quinto es que los objetos lentos no se muevan en absoluto. La posición se guarda como entero y los incrementos de menos de un píxel se pierden. Usa float o pygame.FRect.

El sexto es que un clic en un hueco transparente cuente como impacto. Estás usando el rectángulo de la imagen; usa la distancia al centro o una máscara.

Y el séptimo es instalar pygame y pygame-ce en el mismo entorno, lo que provoca conflictos porque ambos ocupan el mismo módulo. Desinstala uno de ellos.

Conclusiones

Una galería de tiro es un proyecto pequeño, pero reúne casi todos los fundamentos del desarrollo de videojuegos. El bucle principal procesa la entrada, actualiza el mundo y dibuja la escena decenas de veces por segundo, y el tiempo delta hace que el movimiento sea igual en cualquier equipo.

Con Pygame, en su edición comunitaria pygame-ce, hemos cargado imágenes con alternativas de respaldo, generado dianas de forma procedural en filas con distinta velocidad y puntuación, rotado el arma hacia el ratón con atan2 partiendo siempre de la imagen original, y detectado los impactos con la distancia al centro de cada diana.

Una máquina de estados basada en Enum organiza el menú, la partida, la pausa y el fin del juego, y permite añadir modos como contrarreloj o munición limitada con muy poco código. Los récords se guardan en un archivo de texto tolerante a errores y el sonido se integra sin que su ausencia rompa el juego.

A partir de aquí, el proyecto admite muchas ampliaciones: animaciones al acertar, partículas, nuevos tipos de objetivos que penalizan al ser alcanzados, tablas de récords con nombres o versiones para el navegador. Todas se construyen sobre los mismos cimientos que hemos visto.