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):
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:
- Sprite para el fondo 1280x720 HD
- Sprites para las palas
- Sprite para la bola
- Fuente marcador (Micro5 Regular) https://fonts.google.com/specimen/Micro+5
03. Escena
- Crear el proyecto
Neon Pong
y la escena principalPong
. - Establecer el tamaño estudiado en el diseño:
1280x720
a la ventana. - Añadir el Sprite2D
Background
para el fondo y descentrarlo enOffset
.
04. Paredes
- Crear dos nodos
StaticBody2D
(TopWall
yBottomWall
) cada uno con unCollisionShape2D
rectangular que ocupe la parte superior e inferior:
- Crear dos nodos
Area2D
que solo monitoriza colisiones (LeftEdge
yRightEdge
) cada uno con unCollisionShape2D
rectangular que ocupe la parte izquierda y derecha. Podemos darle unos colores distintivos al debug color:
05. Palas
-
Creamos un Nodo llamado
PlayerPaddle
tipoRigidBody2D
con 2 hijosSprite2D
yCollisionShape2D
(Segmento). Le asignamos el spritePaddle_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): -
Experimentar con lo que sucede con un cuerpo rígido por defecto (le afecta la gravedad y además rota si choca con algo):
-
Debemos desactivar la rotación
Deactivation > Lock Rotation
y la gravedadGravity 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 elEnemyPaddle
le añadimos el sprite y la colisiónSegmentShape2D
como con la del jugador y lo dejamos ahí, luego haremos la IA.
06. Bola
- Creamos un nodo tipo
CharacterBody2D
llamadoBall
con 2 hijosSprite2D
yCollisionShape2D
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áticoKinematicBody2D
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 unVector2
con la dirección de la velocidad actual aplicada en los cálculos de la funciónmove_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)
-
Podemos proceder a experimentar con la colisión:
-
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.
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 llamarGameTimer
:- Wait Time:
1 s
- One Shot:
True
- Autostart:
True
- Wait Time:
-
Al
Nodo2D
de la escena le añadiremos un script para controlar el juegoPong.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()
dePong.gd
: -
Ahora debemos enlazar las señales
body_entered(body: Node2D)
de los dos a los eventos: -
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:
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
llamadosPlayerPoints
yEnemyPoints
, con un texto inicial0
en cada uno y centramos el textoHorizontal Align > Center
. -
Configuramos la fuente
Micro5
enTheme Overrides > Fonts
con un tamaño de98px
y le podemos configurar uns colores y un tamaño deOutline
: -
Alineamos correctamente los marcadores donde mejor queden:
-
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:
- Y el videojuego Neon Pong está terminado:
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 laAssetLib
llamadoTrail2D
.
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:
- 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
llamadoui_fullscreen
, activamos enProject Settings > Ventana > Estirar > Viewport
. En el código delPong.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 sonidosBounce.sfxr
,Hit.sfxr
yPoint.sfxr
. Añadir 3AudioStreamPlay2D
aBall
, 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()