← Volver al listado de tecnologías

Capítulo 3: Tu Primer Juego Completo - Pong

Por: Artiko
godotpongtutorialjuego-completofísicauisonido

Capítulo 3: Tu Primer Juego Completo - Pong

¡Es hora de crear tu primer juego completo! Pong es el juego perfecto para empezar: simple en concepto pero cubre todos los aspectos fundamentales del desarrollo de videojuegos. Al terminar este capítulo, tendrás un juego completamente funcional con menú, gameplay, puntuación, sonidos y efectos visuales.

¿Qué Vamos a Construir?

Nuestro Pong tendrá:

Configuración del Proyecto

Crear el Proyecto

  1. Abre Godot y crea un nuevo proyecto

    • Nombre: “Pong Classic”
    • Renderer: Compatibility (para máxima compatibilidad)
    • Versión Control: Git (si usas control de versiones)
  2. Configura la ventana del juego

    • Project → Project Settings → Display → Window
    • Width: 1280
    • Height: 720
    • Stretch Mode: canvas_items
    • Aspect: keep
  3. Configura la física 2D

    • Project → Project Settings → Physics → 2D
    • Default Gravity: 0 (sin gravedad en Pong)

Estructura de Carpetas

Crea esta estructura en el FileSystem:

res://
├── scenes/
│   ├── game/
│   ├── ui/
│   └── components/
├── scripts/
├── assets/
│   ├── sprites/
│   ├── sounds/
│   └── fonts/
└── autoload/

Escena Principal del Juego

Crear la Escena Base

  1. Crea una nueva escena 2D
  2. Renombra el nodo raíz a “Game”
  3. Guárdala como res://scenes/game/game.tscn

Estructura de la Escena

Game (Node2D)
├── Background (ColorRect)
├── Court (Node2D)
│   ├── TopWall (StaticBody2D)
│   │   └── CollisionShape2D
│   ├── BottomWall (StaticBody2D)
│   │   └── CollisionShape2D
│   └── CenterLine (Line2D)
├── PlayerPaddle (CharacterBody2D)
│   ├── Sprite2D
│   └── CollisionShape2D
├── CPUPaddle (CharacterBody2D)
│   ├── Sprite2D
│   └── CollisionShape2D
├── Ball (RigidBody2D)
│   ├── Sprite2D
│   └── CollisionShape2D
├── UI (CanvasLayer)
│   ├── PlayerScore (Label)
│   └── CPUScore (Label)
└── Sounds (Node)
    ├── HitSound (AudioStreamPlayer)
    ├── ScoreSound (AudioStreamPlayer)
    └── WallSound (AudioStreamPlayer)

Configurar el Background

  1. Selecciona Background (ColorRect)
  2. En el Inspector:
    • Anchor: Full Rect
    • Color: #1a1a2e (azul oscuro)

Crear las Paredes

Pared Superior

  1. Selecciona TopWall (StaticBody2D)
  2. Position: (640, -10)
  3. Añade CollisionShape2D como hijo
  4. Shape: New RectangleShape2D
  5. Size: (1280, 20)

Pared Inferior

  1. Selecciona BottomWall
  2. Position: (640, 730)
  3. CollisionShape2D con RectangleShape2D
  4. Size: (1280, 20)

Línea Central

  1. Selecciona CenterLine (Line2D)
  2. Points: Añade dos puntos: (640, 0) y (640, 720)
  3. Width: 4
  4. Default Color: #ffffff50 (blanco semi-transparente)

Creando las Paletas

Paleta del Jugador

  1. Selecciona PlayerPaddle
  2. Position: (50, 360)
  3. Añade el script:
# player_paddle.gd
extends CharacterBody2D

const SPEED = 400.0
const PADDLE_HEIGHT = 100

@export var player_id: int = 1  # 1 para jugador 1, 2 para jugador 2

func _ready():
    # Crear el sprite de la paleta
    var sprite = $Sprite2D
    if sprite.texture == null:
        # Crear textura blanca simple
        var image = Image.create(20, PADDLE_HEIGHT, false, Image.FORMAT_RGB8)
        image.fill(Color.WHITE)
        sprite.texture = ImageTexture.create_from_image(image)
    
    # Configurar colisión
    var collision = $CollisionShape2D
    var shape = RectangleShape2D.new()
    shape.size = Vector2(20, PADDLE_HEIGHT)
    collision.shape = shape

func _physics_process(delta):
    var direction = 0.0
    
    # Controles para jugador 1
    if player_id == 1:
        if Input.is_action_pressed("player1_up"):
            direction = -1.0
        elif Input.is_action_pressed("player1_down"):
            direction = 1.0
    
    # Controles para jugador 2 (modo 2 jugadores)
    elif player_id == 2:
        if Input.is_action_pressed("player2_up"):
            direction = -1.0
        elif Input.is_action_pressed("player2_down"):
            direction = 1.0
    
    velocity.y = direction * SPEED
    move_and_slide()
    
    # Limitar movimiento dentro de la pantalla
    global_position.y = clamp(global_position.y, 50, 670)

func get_paddle_center() -> Vector2:
    return global_position

func get_paddle_height() -> float:
    return PADDLE_HEIGHT

Paleta de la CPU

# cpu_paddle.gd
extends CharacterBody2D

const SPEED = 350.0
const PADDLE_HEIGHT = 100

@export var difficulty: float = 0.8  # 0.0 = fácil, 1.0 = imposible
@export var reaction_distance: float = 400.0

var ball: RigidBody2D
var target_y: float

func _ready():
    # Crear sprite
    var sprite = $Sprite2D
    if sprite.texture == null:
        var image = Image.create(20, PADDLE_HEIGHT, false, Image.FORMAT_RGB8)
        image.fill(Color.WHITE)
        sprite.texture = ImageTexture.create_from_image(image)
    
    # Configurar colisión
    var collision = $CollisionShape2D
    var shape = RectangleShape2D.new()
    shape.size = Vector2(20, PADDLE_HEIGHT)
    collision.shape = shape
    
    # Encontrar la pelota
    await get_tree().process_frame
    ball = get_node_or_null("../Ball")

func _physics_process(delta):
    if not ball:
        return
    
    # IA simple: seguir la pelota cuando está cerca
    var ball_pos = ball.global_position
    var distance_to_ball = abs(ball_pos.x - global_position.x)
    
    # Solo reaccionar cuando la pelota está cerca y viniendo hacia nosotros
    if distance_to_ball < reaction_distance and ball.linear_velocity.x > 0:
        # Predecir donde estará la pelota
        var time_to_reach = distance_to_ball / abs(ball.linear_velocity.x)
        var predicted_y = ball_pos.y + (ball.linear_velocity.y * time_to_reach * 0.5)
        
        # Añadir algo de imperfección basada en dificultad
        var error = randf_range(-50, 50) * (1.0 - difficulty)
        target_y = predicted_y + error
    else:
        # Volver al centro cuando la pelota está lejos
        target_y = 360
    
    # Mover hacia el objetivo
    var direction = sign(target_y - global_position.y)
    velocity.y = direction * SPEED * difficulty
    
    move_and_slide()
    
    # Limitar movimiento
    global_position.y = clamp(global_position.y, 50, 670)

Creando la Pelota

La pelota es el corazón del juego. Necesita física realista y comportamiento predecible.

# ball.gd
extends RigidBody2D

@export var initial_speed: float = 400.0
@export var max_speed: float = 800.0
@export var speed_increment: float = 20.0

signal goal_scored(player_scored: bool)

var current_speed: float
var hits: int = 0

func _ready():
    # Configurar física
    gravity_scale = 0
    linear_damp = 0
    angular_damp = 0
    
    # Configurar bounce
    var physics_material = PhysicsMaterial.new()
    physics_material.bounce = 1.0
    physics_material.friction = 0.0
    physics_material_override = physics_material
    
    # Crear sprite
    var sprite = $Sprite2D
    if sprite.texture == null:
        var image = Image.create(20, 20, false, Image.FORMAT_RGB8)
        image.fill(Color.WHITE)
        # Hacer circular
        for x in range(20):
            for y in range(20):
                var distance = Vector2(x - 10, y - 10).length()
                if distance > 10:
                    image.set_pixel(x, y, Color.TRANSPARENT)
        sprite.texture = ImageTexture.create_from_image(image)
    
    # Configurar colisión
    var collision = $CollisionShape2D
    var shape = CircleShape2D.new()
    shape.radius = 10
    collision.shape = shape
    
    # Conectar señales
    body_entered.connect(_on_body_entered)
    
    # Iniciar juego
    reset_ball()

func reset_ball():
    position = Vector2(640, 360)
    hits = 0
    current_speed = initial_speed
    
    # Esperar un momento antes de lanzar
    linear_velocity = Vector2.ZERO
    await get_tree().create_timer(1.0).timeout
    
    serve_ball()

func serve_ball():
    # Dirección aleatoria
    var direction = Vector2()
    direction.x = 1.0 if randf() > 0.5 else -1.0
    direction.y = randf_range(-0.5, 0.5)
    direction = direction.normalized()
    
    linear_velocity = direction * current_speed

func _on_body_entered(body):
    if body.name == "PlayerPaddle" or body.name == "CPUPaddle":
        # Sonido de golpe
        get_node("../Sounds/HitSound").play()
        
        # Incrementar velocidad
        hits += 1
        current_speed = min(current_speed + speed_increment, max_speed)
        
        # Calcular nuevo ángulo basado en donde golpea la paleta
        var paddle_height = body.get_paddle_height() if body.has_method("get_paddle_height") else 100
        var paddle_center = body.get_paddle_center() if body.has_method("get_paddle_center") else body.global_position
        var hit_position = global_position.y - paddle_center.y
        var normalized_position = hit_position / (paddle_height / 2.0)
        normalized_position = clamp(normalized_position, -1.0, 1.0)
        
        # Nuevo vector de velocidad
        var new_direction = Vector2()
        new_direction.x = -sign(linear_velocity.x)
        new_direction.y = normalized_position * 0.75
        new_direction = new_direction.normalized()
        
        linear_velocity = new_direction * current_speed
        
        # Efecto visual
        create_hit_effect()
    
    elif body.name == "TopWall" or body.name == "BottomWall":
        # Sonido de pared
        get_node("../Sounds/WallSound").play()
        linear_velocity.y = -linear_velocity.y

func create_hit_effect():
    # Crear partículas o efecto visual
    var particles = CPUParticles2D.new()
    particles.emitting = true
    particles.amount = 10
    particles.lifetime = 0.3
    particles.one_shot = true
    particles.speed_scale = 2
    particles.direction = Vector2(-sign(linear_velocity.x), 0)
    particles.initial_velocity_min = 50
    particles.initial_velocity_max = 150
    particles.scale_amount_min = 0.5
    particles.scale_amount_max = 1.0
    particles.color = Color.YELLOW
    
    get_parent().add_child(particles)
    particles.global_position = global_position
    
    # Auto-destruir después de emitir
    await particles.finished
    particles.queue_free()

func _integrate_forces(state):
    # Detectar goles
    if global_position.x < 0:
        goal_scored.emit(false)  # CPU anotó
        reset_ball()
    elif global_position.x > 1280:
        goal_scored.emit(true)  # Jugador anotó
        reset_ball()

Sistema de Puntuación

# game_manager.gd (adjuntar al nodo Game)
extends Node2D

@export var max_score: int = 5
@export var game_mode: String = "1_player"  # "1_player" o "2_players"

var player_score: int = 0
var cpu_score: int = 0

@onready var player_score_label = $UI/PlayerScore
@onready var cpu_score_label = $UI/CPUScore
@onready var ball = $Ball
@onready var player_paddle = $PlayerPaddle
@onready var cpu_paddle = $CPUPaddle

signal game_over(winner: String)

func _ready():
    # Configurar Input Map
    setup_input_actions()
    
    # Conectar señales
    ball.goal_scored.connect(_on_goal_scored)
    
    # Inicializar UI
    update_score_display()
    
    # Configurar modo de juego
    if game_mode == "2_players":
        cpu_paddle.player_id = 2  # Convertir CPU en jugador 2

func setup_input_actions():
    # Jugador 1
    if not InputMap.has_action("player1_up"):
        InputMap.add_action("player1_up")
        var event = InputEventKey.new()
        event.keycode = KEY_W
        InputMap.action_add_event("player1_up", event)
    
    if not InputMap.has_action("player1_down"):
        InputMap.add_action("player1_down")
        var event = InputEventKey.new()
        event.keycode = KEY_S
        InputMap.action_add_event("player1_down", event)
    
    # Jugador 2
    if not InputMap.has_action("player2_up"):
        InputMap.add_action("player2_up")
        var event = InputEventKey.new()
        event.keycode = KEY_UP
        InputMap.action_add_event("player2_up", event)
    
    if not InputMap.has_action("player2_down"):
        InputMap.add_action("player2_down")
        var event = InputEventKey.new()
        event.keycode = KEY_DOWN
        InputMap.action_add_event("player2_down", event)

func _on_goal_scored(player_scored: bool):
    if player_scored:
        player_score += 1
        $Sounds/ScoreSound.play()
        create_goal_text("¡GOL!", Vector2(640, 200), Color.GREEN)
    else:
        cpu_score += 1
        $Sounds/ScoreSound.play()
        create_goal_text("¡GOL!", Vector2(640, 200), Color.RED)
    
    update_score_display()
    
    # Verificar victoria
    if player_score >= max_score:
        game_over.emit("Player")
        show_game_over("¡GANASTE!")
    elif cpu_score >= max_score:
        game_over.emit("CPU")
        show_game_over("GAME OVER")

func update_score_display():
    player_score_label.text = str(player_score)
    cpu_score_label.text = str(cpu_score)

func create_goal_text(text: String, pos: Vector2, color: Color):
    var label = Label.new()
    label.text = text
    label.add_theme_font_size_override("font_size", 72)
    label.modulate = color
    label.position = pos
    
    $UI.add_child(label)
    
    # Animación
    var tween = create_tween()
    tween.set_trans(Tween.TRANS_ELASTIC)
    tween.set_ease(Tween.EASE_OUT)
    tween.tween_property(label, "scale", Vector2(1.5, 1.5), 0.5)
    tween.parallel().tween_property(label, "modulate:a", 0.0, 1.0)
    tween.tween_callback(label.queue_free)

func show_game_over(message: String):
    # Pausar juego
    get_tree().paused = true
    
    # Crear overlay
    var overlay = ColorRect.new()
    overlay.color = Color(0, 0, 0, 0.7)
    overlay.anchor_right = 1.0
    overlay.anchor_bottom = 1.0
    
    var label = Label.new()
    label.text = message
    label.add_theme_font_size_override("font_size", 96)
    label.anchor_left = 0.5
    label.anchor_top = 0.5
    label.anchor_right = 0.5
    label.anchor_bottom = 0.5
    
    $UI.add_child(overlay)
    overlay.add_child(label)
    
    # Botones
    var retry_button = Button.new()
    retry_button.text = "Jugar de Nuevo"
    retry_button.position = Vector2(540, 400)
    retry_button.pressed.connect(_on_retry_pressed)
    
    var menu_button = Button.new()
    menu_button.text = "Menú Principal"
    menu_button.position = Vector2(540, 450)
    menu_button.pressed.connect(_on_menu_pressed)
    
    overlay.add_child(retry_button)
    overlay.add_child(menu_button)

func _on_retry_pressed():
    get_tree().paused = false
    get_tree().reload_current_scene()

func _on_menu_pressed():
    get_tree().paused = false
    get_tree().change_scene_to_file("res://scenes/ui/main_menu.tscn")

Configurando la UI

Labels de Puntuación

  1. PlayerScore (Label)

    • Position: (320, 50)
    • Font Size: 72
    • Text: “0”
    • Align: Center
  2. CPUScore (Label)

    • Position: (960, 50)
    • Font Size: 72
    • Text: “0”
    • Align: Center

Menú Principal

Crea una nueva escena para el menú:

# main_menu.gd
extends Control

@onready var animation_player = $AnimationPlayer

func _ready():
    # Animar entrada
    if animation_player:
        animation_player.play("fade_in")
    
    # Configurar botones
    $VBoxContainer/OnePlayerButton.pressed.connect(_on_one_player_pressed)
    $VBoxContainer/TwoPlayersButton.pressed.connect(_on_two_players_pressed)
    $VBoxContainer/OptionsButton.pressed.connect(_on_options_pressed)
    $VBoxContainer/QuitButton.pressed.connect(_on_quit_pressed)
    
    # Música de menú
    if not $MenuMusic.playing:
        $MenuMusic.play()

func _on_one_player_pressed():
    Global.game_mode = "1_player"
    transition_to_game()

func _on_two_players_pressed():
    Global.game_mode = "2_players"
    transition_to_game()

func _on_options_pressed():
    # Mostrar menú de opciones
    $OptionsMenu.visible = true

func _on_quit_pressed():
    get_tree().quit()

func transition_to_game():
    if animation_player:
        animation_player.play("fade_out")
        await animation_player.animation_finished
    get_tree().change_scene_to_file("res://scenes/game/game.tscn")

Estructura del Menú

MainMenu (Control)
├── Background (TextureRect)
├── Title (Label) - "PONG"
├── VBoxContainer
│   ├── OnePlayerButton (Button)
│   ├── TwoPlayersButton (Button)
│   ├── OptionsButton (Button)
│   └── QuitButton (Button)
├── OptionsMenu (Panel) [Oculto por defecto]
│   ├── DifficultySlider (HSlider)
│   ├── SoundSlider (HSlider)
│   ├── MusicSlider (HSlider)
│   └── BackButton (Button)
├── MenuMusic (AudioStreamPlayer)
└── AnimationPlayer

Sistema Global (Autoload)

Crea un script global para mantener configuraciones entre escenas:

# global.gd (Autoload)
extends Node

var game_mode: String = "1_player"
var difficulty: float = 0.5
var sound_volume: float = 1.0
var music_volume: float = 0.7
var high_score: int = 0

const SAVE_PATH = "user://pong_save.dat"

func _ready():
    load_game()
    
    # Configurar volúmenes
    AudioServer.set_bus_volume_db(
        AudioServer.get_bus_index("Master"),
        linear_to_db(sound_volume)
    )

func save_game():
    var save_file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if save_file:
        var save_data = {
            "high_score": high_score,
            "difficulty": difficulty,
            "sound_volume": sound_volume,
            "music_volume": music_volume
        }
        save_file.store_var(save_data)
        save_file.close()

func load_game():
    if FileAccess.file_exists(SAVE_PATH):
        var save_file = FileAccess.open(SAVE_PATH, FileAccess.READ)
        if save_file:
            var save_data = save_file.get_var()
            high_score = save_data.get("high_score", 0)
            difficulty = save_data.get("difficulty", 0.5)
            sound_volume = save_data.get("sound_volume", 1.0)
            music_volume = save_data.get("music_volume", 0.7)
            save_file.close()

func update_high_score(score: int):
    if score > high_score:
        high_score = score
        save_game()
        return true
    return false

Para registrar el autoload:

  1. Project → Project Settings → Autoload
  2. Path: res://scripts/global.gd
  3. Node Name: “Global”
  4. Click “Add”

Efectos de Sonido y Música

Configurar Audio Buses

  1. Audio → Audio Bus Layout
  2. Crea estos buses:
    • Master (default)
    • Music
    • SFX

Añadir Sonidos

Puedes crear sonidos simples programáticamente:

# sound_generator.gd
extends Node

func create_hit_sound() -> AudioStream:
    var sample_rate = 44100
    var duration = 0.1
    var frequency = 440.0
    
    var audio = AudioStreamWAV.new()
    audio.format = AudioStreamWAV.FORMAT_16_BITS
    audio.mix_rate = sample_rate
    audio.stereo = false
    
    var samples = PackedByteArray()
    for i in range(int(sample_rate * duration)):
        var sample = sin(2.0 * PI * frequency * i / sample_rate)
        # Aplicar envelope
        var envelope = 1.0 - (i / float(sample_rate * duration))
        sample *= envelope
        # Convertir a 16-bit
        var value = int(sample * 32767.0)
        samples.append(value & 0xFF)
        samples.append((value >> 8) & 0xFF)
    
    audio.data = samples
    return audio

func create_score_sound() -> AudioStream:
    # Sonido ascendente
    var sample_rate = 44100
    var duration = 0.5
    
    var audio = AudioStreamWAV.new()
    audio.format = AudioStreamWAV.FORMAT_16_BITS
    audio.mix_rate = sample_rate
    audio.stereo = false
    
    var samples = PackedByteArray()
    for i in range(int(sample_rate * duration)):
        var t = i / float(sample_rate)
        var frequency = 220.0 * (1.0 + t * 2.0)  # Frecuencia ascendente
        var sample = sin(2.0 * PI * frequency * t)
        var envelope = 1.0 - (t / duration)
        sample *= envelope
        
        var value = int(sample * 32767.0)
        samples.append(value & 0xFF)
        samples.append((value >> 8) & 0xFF)
    
    audio.data = samples
    return audio

Mejoras Visuales

Estela de la Pelota

# ball_trail.gd
extends Line2D

@export var trail_length: int = 20
var trail_points: Array = []

func _ready():
    width = 10
    default_color = Color(1, 1, 1, 0.5)
    gradient = Gradient.new()
    gradient.add_point(0.0, Color(1, 1, 1, 0))
    gradient.add_point(1.0, Color(1, 1, 1, 0.5))

func _process(delta):
    var parent = get_parent()
    if parent:
        var current_pos = parent.global_position
        trail_points.append(current_pos)
        
        if trail_points.size() > trail_length:
            trail_points.pop_front()
        
        clear_points()
        for point in trail_points:
            add_point(to_local(point))

Efectos de Partículas

# particle_manager.gd
extends Node

func create_goal_particles(position: Vector2, color: Color):
    var particles = CPUParticles2D.new()
    particles.emitting = true
    particles.amount = 50
    particles.lifetime = 1.0
    particles.one_shot = true
    particles.emission_shape = CPUParticles2D.EMISSION_SHAPE_SPHERE
    particles.spread = 45.0
    particles.initial_velocity_min = 100
    particles.initial_velocity_max = 300
    particles.angular_velocity_min = -180
    particles.angular_velocity_max = 180
    particles.scale_amount_min = 0.5
    particles.scale_amount_max = 1.5
    particles.color = color
    
    get_tree().current_scene.add_child(particles)
    particles.global_position = position
    
    await particles.finished
    particles.queue_free()

Power-Ups (Característica Extra)

# power_up.gd
extends Area2D

enum PowerUpType {
    SPEED_BOOST,
    SLOW_MOTION,
    MULTI_BALL,
    GIANT_PADDLE,
    TINY_PADDLE
}

@export var type: PowerUpType = PowerUpType.SPEED_BOOST
@export var duration: float = 5.0

signal collected(type: PowerUpType)

func _ready():
    # Configurar visual según tipo
    var sprite = $Sprite2D
    match type:
        PowerUpType.SPEED_BOOST:
            modulate = Color.YELLOW
        PowerUpType.SLOW_MOTION:
            modulate = Color.BLUE
        PowerUpType.MULTI_BALL:
            modulate = Color.GREEN
        PowerUpType.GIANT_PADDLE:
            modulate = Color.ORANGE
        PowerUpType.TINY_PADDLE:
            modulate = Color.PURPLE
    
    # Conectar señales
    body_entered.connect(_on_body_entered)
    
    # Auto-destruir después de un tiempo
    await get_tree().create_timer(10.0).timeout
    queue_free()

func _on_body_entered(body):
    if body.name == "Ball":
        collected.emit(type)
        
        # Efecto visual
        var tween = create_tween()
        tween.tween_property(self, "scale", Vector2(1.5, 1.5), 0.2)
        tween.parallel().tween_property(self, "modulate:a", 0.0, 0.2)
        tween.tween_callback(queue_free)
        
        # Aplicar efecto
        apply_power_up(body)

func apply_power_up(ball):
    match type:
        PowerUpType.SPEED_BOOST:
            ball.current_speed *= 1.5
        PowerUpType.SLOW_MOTION:
            Engine.time_scale = 0.5
            await get_tree().create_timer(duration * Engine.time_scale).timeout
            Engine.time_scale = 1.0
        # ... más efectos

Puliendo el Juego

Menú de Pausa

# pause_menu.gd
extends Control

var paused: bool = false

func _ready():
    visible = false
    process_mode = Node.PROCESS_MODE_WHEN_PAUSED

func _input(event):
    if event.is_action_pressed("ui_cancel"):
        toggle_pause()

func toggle_pause():
    paused = !paused
    get_tree().paused = paused
    visible = paused
    
    if paused:
        Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
    else:
        Input.mouse_mode = Input.MOUSE_MODE_HIDDEN

func _on_resume_pressed():
    toggle_pause()

func _on_restart_pressed():
    toggle_pause()
    get_tree().reload_current_scene()

func _on_quit_pressed():
    toggle_pause()
    get_tree().change_scene_to_file("res://scenes/ui/main_menu.tscn")

Testing y Debugging

Sistema de Debug

# debug_overlay.gd
extends Control

@export var show_debug: bool = false

@onready var fps_label = $FPSLabel
@onready var ball_speed_label = $BallSpeedLabel
@onready var ball_position_label = $BallPositionLabel

func _ready():
    visible = OS.is_debug_build()

func _process(delta):
    if Input.is_action_just_pressed("toggle_debug"):
        show_debug = !show_debug
        visible = show_debug
    
    if show_debug:
        fps_label.text = "FPS: " + str(Engine.get_frames_per_second())
        
        var ball = get_node_or_null("/root/Game/Ball")
        if ball:
            ball_speed_label.text = "Speed: " + str(int(ball.linear_velocity.length()))
            ball_position_label.text = "Pos: " + str(ball.global_position)

Exportando el Juego

Preparar para Exportación

  1. Optimizar assets

    • Comprimir imágenes
    • Reducir calidad de audio si es necesario
  2. Configurar iconos

    • Project Settings → Application → Config → Icon
    • Usa un PNG de 256x256
  3. Configurar pantalla de inicio

    • Project Settings → Application → Boot Splash

Exportar a Diferentes Plataformas

Windows

  1. Project → Export → Add → Windows Desktop
  2. Export Path: builds/windows/Pong.exe
  3. Export Project

HTML5

  1. Project → Export → Add → HTML5
  2. Export Path: builds/html5/index.html
  3. Head Include:
<style>
    body { margin: 0; padding: 0; background: #000; }
    #canvas { display: block; margin: 0 auto; }
</style>

Android

  1. Instalar Android SDK
  2. Configurar en Editor Settings
  3. Project → Export → Add → Android
  4. Configurar package name: com.tuempresa.pong
  5. Export APK

Resumen del Capítulo

¡Felicidades! Has creado tu primer juego completo con:

Ejercicios y Mejoras

Fácil

  1. Añade diferentes niveles de dificultad
  2. Implementa un contador de rally (golpes consecutivos)
  3. Añade más efectos de sonido

Medio

  1. Crea diferentes modos de juego (primer a 11, tiempo límite)
  2. Implementa power-ups aleatorios
  3. Añade un sistema de logros

Difícil

  1. Modo torneo con brackets
  2. Multijugador online básico
  3. Tabla de puntuaciones online

Próximo Capítulo

En el siguiente capítulo profundizaremos en el sistema de nodos y escenas de Godot:

→ Capítulo 4: Sistema de Nodos y Escenas


Proyecto Final: Añade tu toque personal al Pong. ¿Tema retro? ¿Estilo neón? ¿Mecánicas únicas? ¡Comparte tu versión en itch.io!