01 Pong (1972) - hektorprofe/godot-arcades GitHub Wiki

Curso Neon Pong en Godot

Vídeo completo disponible en Odysse (o haz clic en la imagen para acceder):

Curso Neon Pong en Godot 4

Simplemente controlas paletas que mueven de arriba a abajo para rebotar una bola. Es el más sencillo en términos de mecánicas y gráficos.

01. Historia

Bienvenidos a la primera unidad de esta serie gratuita sobre videojuegos Arcade aquí en Hektor Profe.

Hoy vamos a embarcarnos en un viaje a través del tiempo para descubrir la historia de Pong, el primer videojuego comercial exitoso, y entender su monumental importancia en la industria de los videojuegos.

En 1972 la empresa Atari lanzó Pong, un juego simple de tenis de mesa. A pesar de su sencillez, marcó el comienzo de la era de los videojuegos comerciales. Fue diseñado por el ingeniero electrónico Allan Alcorn como un ejercicio de entrenamiento, pero su éxito fue tan grande que se convirtió en el primer producto comercialmente exitoso de Atari, estableciendo a la compañía como un líder en la industria de los videojuegos.

En lo jugable se trata de un videojuego para dos jugadores donde cada jugador controla una paleta en la pantalla con el objetivo de golpear una bola y pasarla más allá de la paleta del oponente. Lo que hoy puede parecer extremadamente simple, en aquel entonces fue una revolución tecnológica que nos enseña la importancia de la simplicidad en el diseño de videojuegos. Pues a menudo, las ideas más simples, son las que resuenan más con el público.

Pong fue un hito tanto comercial como cultural. Introdujo el concepto de los videojuegos al gran público, abriendo el camino para la industria multimillonaria que conocemos hoy. Desde el punto de vista tecnológico, impulsó la innovación en hardware y software de videojuegos, sentando las bases para el futuro desarrollo de videojuegos más complejos, inspirando a innumerables desarrolladores y generando una competencia feroz en el naciente mercado de los videojuegos. No es exagerado decir que muchas empresas y tecnologías nacieron a partir del éxito de Pong.

Se convirtió en una sensación cultural, apareciendo en bares y arcades, y convirtiéndose en un fenómeno social. Incluso hoy, su legado sigue vivo en la cultura pop y en la inspiración que ofrece a los nuevos desarrolladores como tú. Comprender su historia y el impacto que causó nos ofrece una valiosa lección sobre cómo la innovación y la simplicidad pueden ir de la mano para crear algo que trascienda generaciones.

A lo largo de las próximas lecciones aprenderás paso a paso a recrear tu propia versión de este histórico juego con un enemigo controlado por la CPU.

02. Recursos

Estudiar un diseño en Krita para entender los tamaños con los siguientes elementos:

Sample

03. Escena

  • Crear el proyecto Neon Pong y la escena principal Pong.
  • Establecer el tamaño estudiado en el diseño: 1280x720 a la ventana.
  • Añadir el Sprite2D Background para el fondo y descentrarlo en Offset.

04. Paredes

  • Crear dos nodos StaticBody2D (TopWall y BottomWall) cada uno con un CollisionShape2D rectangular que ocupe la parte superior e inferior:

image

  • Crear dos nodos Area2D que solo monitoriza colisiones (LeftEdge y RightEdge) cada uno con un CollisionShape2D rectangular que ocupe la parte izquierda y derecha. Podemos darle unos colores distintivos al debug color:

image

05. Palas

  • Creamos un Nodo llamado PlayerPaddle tipo RigidBody2D con 2 hijos Sprite2D y CollisionShape2D (Segmento). Le asignamos el sprite Paddle_1.png y le damos la forma de colisión muy fina (esto reducirá las posibilidades de que la bola se quede atrapada verticalmente entre la pala y la pared):

    image

  • Experimentar con lo que sucede con un cuerpo rígido por defecto (le afecta la gravedad y además rota si choca con algo):

    pong1

  • Debemos desactivar la rotación Deactivation > Lock Rotation y la gravedad Gravity Scale > 0.

  • Añadimos un script para mover la pala usando fuerzas fisicas:

# PlayerPaddle.gd
extends RigidBody2D

@export var speed = 450
	
func _physics_process(delta):
    # Definimos un vector de dirección
    var movement = Vector2.ZERO
    # Dependiendo del control lo asignamos arriba o abajo
    if Input.is_action_pressed("ui_up"): 
        movement = Vector2.UP
    elif Input.is_action_pressed("ui_down"): 
        movement = Vector2.DOWN
    # Realizamos el movimiento y las colisiones cinemáticas
    linear_velocity = movement * speed
  • Creamos otro RigidBody2D para el EnemyPaddle le añadimos el sprite y la colisión SegmentShape2D como con la del jugador y lo dejamos ahí, luego haremos la IA.

image

06. Bola

  • Creamos un nodo tipo CharacterBody2D llamado Ball con 2 hijos Sprite2D y CollisionShape2D circular (importante).
  • ¿Por qué un CharacterBody2D si en realidad nuestro personaje es la paleta? Pues porque como explica la definición del tipo: Se trata de un cuerpo físico 2D especializado en personajes movidos mediante scripts de código. Antes de Godot 4 este tipo se llamaba cuerpo cinemático KinematicBody2D pero ha sido simplificado para llamarlo simplemente cuerpo de personaje. Este Nodo contiene funciones internas para detectar colisiones y realizar movimientos que nos vendrán muy bien al trabajar con físicas.
  • El script de movimiento de la bola es el siguiente:
# Ball.gd
extends CharacterBody2D

class_name Ball  # Esto nos servirá para saber luego si un nodo es una bola

@export var speed = 15

func _ready() -> void:
    randomize()  # Generamos la semilla de aleatoriedad
    start()  # Esto es temporal, más adelante usaremos un temporizador

func _physics_process(delta):
    move_and_collide(velocity * speed * delta)

func start() -> void:
    # Reiniciamos la posición y la velocidad
    # Y: [-1 izquierda, 1 derecha] - Y: [-1 arriba, 1 abajo]
    velocity.x = [-1, 1].pick_random() * speed
    velocity.y = [-1, 1].pick_random() * speed
  • La propiedad velocity almacena un Vector2 con la dirección de la velocidad actual aplicada en los cálculos de la función move_and_slide().
  • La bola se quedará pegada al colisionar con una pared, debemos programar lo que debe hacer al recibir una colisión:
func _physics_process(delta):
    var collision = move_and_collide(velocity * speed * delta)
    # Si hay una colisión rebotamos a 90º, el vector de la normal resultante
    if collision:
        var collision_normal = collision.get_normal()
        velocity = velocity.bounce(collision_normal)

image

  • Podemos proceder a experimentar con la colisión:

    pong2

  • Para que el juego se vuelva más difícil con cada colisión, podemos incrementar su velocidad al rebotar, esto al gusto de cada uno:

@export var speed_multiplier = 1.03 # 3% más rápido por colisión

func _physics_process(delta):
    var collision = move_and_collide(velocity * speed * delta)
    # Si hay una colisión rebotamos a 90º, el vector de la normal resultante
    if collision:
        velocity = velocity.bounce(collision.get_normal()) * speed_multiplier
  • Con esto la jugabilidad central del juego está programada.

07. Enemigo

# EnemyPaddle.gd
extends RigidBody2D

@export var speed = 450
@export var ball : Ball  # Asi podremos seleccionarla

func _physics_process(delta: float) -> void:
    var direction = (ball.position - position).normalized()
    linear_velocity.y = direction.y * speed
  • Asignamos la Ball al nodo manualmente.
  • En este punto el enemigo debería moverse verticalmente con la bola.

pong3

08. Bucle de Juego

  • El bucle de juego es el proceso repetitivo que mantiene la jugabilidad en marcha. En este aso es muy simple porque tiene la duración de una ronda. Cada ronda acaba cuando un jugador marca punto y a continuación empieza la siguiente ronda.

  • Al principio de cada ronda reiniciaremos la posición de la bola y las palas para empezar la partida. Para hacerlo automáticamente vamos a crear un Nodo Timer en la escena que nos sirva de temporizador para iniciar el juego, lo podemos llamar GameTimer:

    • Wait Time: 1 s
    • One Shot: True
    • Autostart: True
  • Al Nodo2D de la escena le añadiremos un script para controlar el juego Pong.gd:

extends Node2D
	
# Evento que detectará un punto del jugador
func _on_player_scored() -> void:
    $GameTimer.start()
	
# Evento que detectará un punto del enemigo
func _on_enemy_scored() -> void:
    $GameTimer.start()

# Función para reiniciar el juego
func start_game() -> void:
    $Ball.start()
  • Procederemos a reiniciar la posición de la bola:
# Ball.gd
var initial_position : Vector2

func _ready() -> void:
    # start() # <--- BORRAMOS ESTA LINEA, SE LLAMA CON EL TIMER
    initial_position = global_position

func start() -> void:
    # Reiniciamos la posición y la velocidad
    position = initial_position # <--- nuevo
  • Configuraremos el timer para que ejecute la función start_game() de Pong.gd:

    image

  • Ahora debemos enlazar las señales body_entered(body: Node2D) de los dos a los eventos:

    image

  • No nos dejará porque no tienen la misma firma, es decir, los campos recibidos o el valor retornado no son los esperados. Para solucionarlo simplemente añadiremos que se recibe un campo body: Node2D en ambos eventos, además podemos comprobar si la colisión viene desde una bola, lo que nos evitará recibir colisiones con las palas:

func _on_player_scored(body: Node2D) -> void:
    if body is Ball:
        # ...
	
func _on_enemy_scored(body: Node2D) -> void:
    if body is Ball:
        # ...
  • Ahora si nos dejará enlazar ambos, para el lado izquierdo cuando anote el enemigo y para el lado derecho cuando anota el jugador, no olvidemos conectar ambas señales.
  • El bucle de juego esta listo, solo nos falta crear la interfaz:

pong4

09. Interfaz Gráfica

  • Nuestra interfaz gráfica es muy simple, solo tendremos dos marcadores de puntos, uno para cada jugador.

  • Creamos dos Nodos de tipo Label llamados PlayerPoints y EnemyPoints, con un texto inicial 0 en cada uno y centramos el texto Horizontal Align > Center.

  • Configuramos la fuente Micro5 en Theme Overrides > Fonts con un tamaño de 98px y le podemos configurar uns colores y un tamaño de Outline:

    image

  • Alineamos correctamente los marcadores donde mejor queden:

    image

  • Para actualizar los puntos crearemos un script llamado LabelPoints.gd con el contenido siguiente:

extends Label

var points : int = 0

func _ready() -> void:
    text = "0"

func score() -> void:
    points += 1
    text = str(points)
  • Asignaremos este script a las dos labels, al fin y al cabo hacen lo mismo.
  • Ahora solo tenemos que llamar estas funciones al anotar puntos desde nuestro Pong.gd, desde donde podemos manejar los puntos:
extends Node2D

func _on_player_scored(body: Node2D) -> void:
    if body is Ball:
        $PlayerPoints.score()
        $GameTimer.start()
	
func _on_enemy_scored(body: Node2D) -> void:
    if body is Ball:
        $PlayerPoints.score()
        $GameTimer.start()
  • Solo debemos asegurarnos que los nodos de interfaz estén por debajo de la bola en el árbol:

image

  • Y el videojuego Neon Pong está terminado:

pong6

10. Detalles y Efectos

  • Bug desplazamiento horizontal de las palas al recibir fuerzas externas: Lo solucionaremos almacenando la posición inicial y rectificándola en cada fotograma para ambas palas:
# PlayerPaddle.gd y EnemyPaddle.gd
func _physics_process(delta: float) -> void:
    # ...

    # Evitar bug movimiento horizontal
    global_position.x = initial_position.x
  • Suavizar el movimiento con un rastro: Se puede programar un sistema de rastro usando Line2D como base pero es más fácil utilizar un addon de la AssetLib llamado Trail2D.

image

Lo instalamos y activamos y podremos crear un nodo Trail2D en la Ball, justo encima del sprite para que salga debajo. Es fácil de configurarlo:

image

  • Bug rastro al reiniciar: Necesitamos borrar los puntos justo cuando inicia el juego para evitar el bug del rastro moviéndose por la pantalla:
# Ball.gd
func start() -> void:
    # Reseteamos el rastro
    $Trail2D.clear_points()
  • Modo jugador automático: Es útil para hacer testeos:
# PaddleUser.gd
@export var auto_mode = false
@export var ball : Ball  # asignamos la bola manualmente

func _physics_process(delta):
    global_position.x = initial_position.x  # Evitar bug movimiento horizontal
    if not auto_mode:
        # Modo manual
        # ...
    else:
        # Modo automatizado
        var direction = (ball.position - position).normalized()
        linear_velocity.y = direction.y * speed
  • Opción de salir con ESC:
# Pong.gd
func _input(event):
    # opción para salir presionando ESC
    if event.is_action_pressed("ui_cancel"):
        get_tree().quit() 
  • Modo pantalla completa: Creamos el nuevo input con la tecla F llamado ui_fullscreen, activamos en Project Settings > Ventana > Estirar > Viewport. En el código del Pong.gd comprobamos en un _process si se hace la combinación:
# Pong.gd
func _input(event):
    # opción para salir presionando ESC
    if event.is_action_pressed("ui_cancel"):
        get_tree().quit() 
    # opción para cambiar a pantalla completa
    if event.is_action_pressed("ui_fullscreen"):
        fullscreen = !fullscreen
        if fullscreen: 
            DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
        else: 
            DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
  • Bug línea en el borde en modo pantalla completa: Es el color de fondo de la ventana, si lo ponemos negro se soluciona Settings > Renderizado > Entorno > Color Claro Predeterminado.

  • Efectos de Audio: Instalar el paquete gdfxr, crear dos sonidos Bounce.sfxr, Hit.sfxr y Point.sfxr. Añadir 3 AudioStreamPlay2D a Ball, asignarles los efectos de sonido y llamarlos al marcar punto o al rebotar contra una pared:

# Pong.gd
func _on_player_scored(body: Node2D) -> void:
    if body is Ball:
        $Ball/Point.play() # <---
	
func _on_enemy_scored(body: Node2D) -> void:
    if body is Ball:
        $Ball/Point.play() # <---

# Ball.gd
func _physics_process(delta):
    # ...
    if collision.get_collider() is RigidBody2D:
        $Hit.play()
    elif collision.get_collider() is StaticBody2D:
        $Bounce.play()