← Volver al listado de tecnologías

Juego de Plataformas Completo

Por: Artiko
defoldplataformasplatformertilemapanimacionesgameplay

Juego de Plataformas Completo

En esta lección crearemos un juego de plataformas completo y pulido que incluye todas las mecánicas clásicas del género. Aprenderás técnicas avanzadas de control de personajes, diseño de niveles con tilemaps, y sistemas de progresión.

Características del Juego

Nuestro platformer incluirá:

Configuración del Proyecto

Estructura de Archivos

platformer_game/
├── player/
│   ├── player.go
│   ├── player.script
│   └── player_animations.script
├── enemies/
│   ├── goomba.go
│   ├── goomba.script
│   ├── flying_enemy.go
│   └── enemy_ai.script
├── levels/
│   ├── level_01.collection
│   ├── level_01.tilemap
│   ├── level_manager.script
│   └── checkpoint.go
├── collectibles/
│   ├── gem.go
│   ├── powerup.go
│   └── collectible.script
├── ui/
│   ├── game_ui.gui
│   └── level_complete.gui
└── assets/
    ├── sprites/
    ├── tilemaps/
    └── sounds/

Configuración de Física

Primero configuremos el sistema de física en game.project:

[physics]
type = 2D
gravity_y = -800
debug = 0
max_collisions = 64
max_contacts = 32
velocity_iterations = 6
position_iterations = 2

Parte 1: Personaje del Jugador

Player Game Object y Físicas

-- player/player.script
go.property("max_speed", 200)
go.property("acceleration", 800)
go.property("jump_power", 450)
go.property("friction", 0.85)

function init(self)
    -- Estado del personaje
    self.velocity = vmath.vector3(0, 0, 0)
    self.grounded = false
    self.ground_contact_count = 0

    -- Mecánicas avanzadas de salto
    self.coyote_time = 0.15      -- Tiempo para saltar después de dejar plataforma
    self.coyote_timer = 0
    self.jump_buffer_time = 0.1   -- Buffer para input de salto temprano
    self.jump_buffer_timer = 0
    self.variable_jump_time = 0.3 -- Tiempo para controlar altura del salto
    self.jump_timer = 0
    self.jumping = false

    -- Animaciones
    self.current_animation = ""
    self.facing_right = true

    -- Control
    self.input = {
        left = false,
        right = false,
        jump = false,
        jump_pressed = false
    }

    msg.post(".", "acquire_input_focus")
    print("Player inicializado")
end

function update(self, dt)
    -- Actualizar timers
    update_timers(self, dt)

    -- Detectar estado de suelo
    update_ground_state(self)

    -- Procesar input
    handle_horizontal_movement(self, dt)
    handle_jumping(self, dt)

    -- Aplicar velocidad con límites
    apply_velocity_limits(self)

    -- Actualizar animaciones
    update_animations(self)

    -- Aplicar fricción
    apply_friction(self, dt)
end

function update_timers(self, dt)
    -- Coyote time (tiempo de gracia para saltar)
    if self.grounded then
        self.coyote_timer = self.coyote_time
    else
        self.coyote_timer = math.max(0, self.coyote_timer - dt)
    end

    -- Jump buffer (buffer para input de salto)
    if self.jump_buffer_timer > 0 then
        self.jump_buffer_timer = self.jump_buffer_timer - dt
    end

    -- Variable jump timer
    if self.jump_timer > 0 then
        self.jump_timer = self.jump_timer - dt
    end
end

function update_ground_state(self)
    self.grounded = self.ground_contact_count > 0

    -- Debug visual
    if self.grounded then
        sprite.set_constant("#sprite", "tint", vmath.vector4(1, 1, 1, 1))
    else
        sprite.set_constant("#sprite", "tint", vmath.vector4(1, 0.8, 0.8, 1))
    end
end

function handle_horizontal_movement(self, dt)
    local input_force = 0

    if self.input.left then
        input_force = -self.acceleration
        self.facing_right = false
    elseif self.input.right then
        input_force = self.acceleration
        self.facing_right = true
    end

    -- Aplicar fuerza horizontal
    if input_force ~= 0 then
        self.velocity.x = self.velocity.x + input_force * dt
    end
end

function handle_jumping(self, dt)
    -- Procesar input de salto
    if self.input.jump_pressed then
        self.jump_buffer_timer = self.jump_buffer_time
        self.input.jump_pressed = false
    end

    -- Iniciar salto si es posible
    if self.jump_buffer_timer > 0 and (self.grounded or self.coyote_timer > 0) then
        start_jump(self)
        self.jump_buffer_timer = 0
        self.coyote_timer = 0
    end

    -- Variable jump height
    if self.jumping and self.input.jump and self.jump_timer > 0 then
        -- Mantener fuerza de salto mientras se presiona
        physics.apply_force(".", vmath.vector3(0, self.jump_power * 0.3, 0))
    else
        self.jumping = false
    end
end

function start_jump(self)
    self.velocity.y = self.jump_power
    self.jumping = true
    self.jump_timer = self.variable_jump_time

    -- Efecto de sonido
    msg.post("#jump_sound", "play_sound")

    -- Efecto visual
    create_jump_effect(self)

    print("¡Salto!")
end

function apply_velocity_limits(self)
    -- Limitar velocidad horizontal
    self.velocity.x = math.max(-self.max_speed, math.min(self.max_speed, self.velocity.x))

    -- Limitar velocidad vertical (terminal velocity)
    self.velocity.y = math.max(-600, self.velocity.y)

    -- Aplicar velocidad al motor de física
    physics.set_velocity(".", self.velocity)
end

function apply_friction(self, dt)
    if self.grounded and not (self.input.left or self.input.right) then
        self.velocity.x = self.velocity.x * self.friction
    end
end

function update_animations(self)
    local target_animation = determine_animation(self)

    if target_animation ~= self.current_animation then
        play_animation(self, target_animation)
        self.current_animation = target_animation
    end

    -- Voltear sprite según dirección
    local scale = go.get_scale()
    scale.x = self.facing_right and math.abs(scale.x) or -math.abs(scale.x)
    go.set_scale(scale)
end

function determine_animation(self)
    if not self.grounded then
        if self.velocity.y > 50 then
            return "jump"
        else
            return "fall"
        end
    elseif math.abs(self.velocity.x) > 20 then
        return "run"
    else
        return "idle"
    end
end

function play_animation(self, animation_name)
    sprite.play_flipbook("#sprite", animation_name)
    print("Animación:", animation_name)
end

function on_input(self, action_id, action)
    if action_id == hash("left") then
        self.input.left = action.pressed or action.repeated
    elseif action_id == hash("right") then
        self.input.right = action.pressed or action.repeated
    elseif action_id == hash("jump") then
        self.input.jump = action.pressed or action.repeated
        if action.pressed then
            self.input.jump_pressed = true
        end
    end
end

function on_message(self, message_id, message, sender)
    if message_id == hash("contact_point_response") then
        handle_collision(self, message)
    elseif message_id == hash("trigger_response") then
        handle_trigger(self, message)
    elseif message_id == hash("take_damage") then
        take_damage(self, message.amount or 1)
    end
end

function handle_collision(self, message)
    local other_group = message.other_group

    if other_group == hash("ground") then
        if message.normal.y > 0.7 then  -- Superficie mayormente horizontal
            if not self.grounded then
                self.ground_contact_count = self.ground_contact_count + 1
                land_on_ground(self)
            end
        elseif math.abs(message.normal.x) > 0.7 then  -- Pared
            -- Cancelar velocidad horizontal si golpea pared
            if (message.normal.x > 0 and self.velocity.x < 0) or
               (message.normal.x < 0 and self.velocity.x > 0) then
                self.velocity.x = 0
            end
        end
    elseif other_group == hash("enemy") then
        -- Verificar si salta sobre enemigo
        if message.normal.y > 0.7 and self.velocity.y < 0 then
            bounce_off_enemy(self)
            msg.post(message.other_id, "stomped")
        else
            take_damage(self, 1)
        end
    end
end

function land_on_ground(self)
    if self.velocity.y < -100 then  -- Caída significativa
        create_landing_effect(self)
        msg.post("#land_sound", "play_sound")
    end
end

function bounce_off_enemy(self)
    self.velocity.y = self.jump_power * 0.7  -- Rebote menor que salto normal
    create_stomp_effect(self)
    msg.post("#stomp_sound", "play_sound")
end

Sistema de Efectos Visuales

-- player/player_effects.script
function create_jump_effect(player_script)
    local pos = go.get_position()
    pos.y = pos.y - 20  -- Efecto en los pies

    -- Crear partículas de polvo
    factory.create("/effects#dust_factory", pos, nil, {
        particle_count = 5,
        direction = vmath.vector3(0, -1, 0)
    })
end

function create_landing_effect(player_script)
    local pos = go.get_position()
    pos.y = pos.y - 20

    factory.create("/effects#dust_factory", pos, nil, {
        particle_count = 8,
        direction = vmath.vector3(0, -1, 0)
    })

    -- Screen shake sutil
    msg.post("/camera", "shake", {intensity = 2, duration = 0.1})
end

function create_stomp_effect(player_script)
    local pos = go.get_position()

    factory.create("/effects#stomp_factory", pos, nil, {
        particle_count = 10,
        color = vmath.vector4(1, 1, 0, 1)
    })
end

Parte 2: Construcción de Niveles con Tilemaps

Configurar Tilemap

  1. Assets → New → Tile Sourcelevel_tiles.tilesource
  2. Configurar:
    • Image: /assets/tilemap_sheet.png
    • Tile Width/Height: 32
    • Collision Groups: ground, platform, spike

Crear Nivel con Tilemap

-- levels/level_01.collection estructura:
level_01/
├── background (Game Object)
│   └── tilemap (Tilemap)
├── foreground (Game Object)
│   └── tilemap (Tilemap)
├── collision_layer (Game Object)
│   └── tilemap (Tilemap) - Solo colisión
├── player_spawn (Game Object)
├── checkpoints/ (Collection)
└── collectibles/ (Collection)

Level Manager

-- levels/level_manager.script
function init(self)
    -- Estado del nivel
    self.gems_collected = 0
    self.total_gems = 0
    self.checkpoints_activated = {}
    self.current_checkpoint = "/level/player_spawn"

    -- Configuración de cámara
    self.camera_bounds = {
        left = 0,
        right = 2048,  -- Ancho del nivel
        top = 640,
        bottom = 0
    }

    -- Contar collectibles
    count_level_collectibles(self)

    print("Level Manager inicializado")
    print("Gemas en el nivel:", self.total_gems)
end

function count_level_collectibles(self)
    -- Contar todos los collectibles en el nivel
    self.total_gems = #go.get_children("/level/collectibles")
end

function update(self, dt)
    -- Actualizar cámara para seguir al jugador
    update_camera_position(self)

    -- Verificar condiciones de victoria
    check_level_completion(self)
end

function update_camera_position(self)
    local player_pos = go.get_position("/level/player")
    local camera_pos = go.get_position("/camera")

    -- Calcular nueva posición de cámara
    local target_x = math.max(self.camera_bounds.left + 480,
                             math.min(self.camera_bounds.right - 480, player_pos.x))
    local target_y = math.max(self.camera_bounds.bottom + 320,
                             math.min(self.camera_bounds.top - 320, player_pos.y))

    -- Movimiento suave de cámara (lerp)
    local lerp_speed = 5
    camera_pos.x = camera_pos.x + (target_x - camera_pos.x) * lerp_speed * (1/60)
    camera_pos.y = camera_pos.y + (target_y - camera_pos.y) * lerp_speed * (1/60)

    go.set_position(camera_pos, "/camera")
end

function check_level_completion(self)
    if self.gems_collected >= self.total_gems then
        if not self.level_completed then
            complete_level(self)
        end
    end
end

function complete_level(self)
    self.level_completed = true
    print("¡Nivel completado!")

    -- Mostrar UI de nivel completo
    msg.post("/ui#level_complete", "show", {
        gems_collected = self.gems_collected,
        total_gems = self.total_gems,
        time_taken = self.level_time
    })

    -- Desbloquear siguiente nivel
    msg.post("/game_manager", "unlock_next_level")
end

function on_message(self, message_id, message, sender)
    if message_id == hash("gem_collected") then
        self.gems_collected = self.gems_collected + 1
        print("Gemas:", self.gems_collected .. "/" .. self.total_gems)

        -- Actualizar UI
        msg.post("/ui#game_hud", "update_gems", {
            collected = self.gems_collected,
            total = self.total_gems
        })

    elseif message_id == hash("checkpoint_activated") then
        activate_checkpoint(self, sender)

    elseif message_id == hash("player_died") then
        respawn_player(self)

    elseif message_id == hash("player_fell") then
        -- Jugador cayó del mapa
        msg.post("/level/player", "take_damage", {amount = 1})
    end
end

function activate_checkpoint(self, checkpoint_id)
    if not self.checkpoints_activated[checkpoint_id] then
        self.checkpoints_activated[checkpoint_id] = true
        self.current_checkpoint = checkpoint_id

        print("Checkpoint activado:", checkpoint_id)

        -- Efecto visual y sonido
        msg.post(checkpoint_id, "activate_visual")
        msg.post("#checkpoint_sound", "play_sound")
    end
end

function respawn_player(self)
    local checkpoint_pos = go.get_position(self.current_checkpoint)
    go.set_position(checkpoint_pos, "/level/player")

    -- Resetear velocidad del jugador
    physics.set_velocity("/level/player", vmath.vector3(0, 0, 0))

    print("Jugador respawneado en:", checkpoint_pos)
end

Parte 3: Enemigos con IA

Goomba Básico

-- enemies/goomba.script
go.property("patrol_distance", 100)
go.property("move_speed", 50)
go.property("detection_range", 80)

function init(self)
    self.state = "patrol"  -- "patrol", "chase", "stunned"
    self.direction = 1     -- 1 = derecha, -1 = izquierda
    self.start_pos = go.get_position()
    self.patrol_left = self.start_pos.x - self.patrol_distance/2
    self.patrol_right = self.start_pos.x + self.patrol_distance/2

    self.health = 1
    self.stunned_timer = 0

    print("Goomba inicializado en:", self.start_pos)
end

function update(self, dt)
    if self.state == "stunned" then
        self.stunned_timer = self.stunned_timer - dt
        if self.stunned_timer <= 0 then
            self.state = "patrol"
        end
        return
    end

    local player_pos = go.get_position("/level/player")
    local my_pos = go.get_position()
    local distance_to_player = vmath.length(player_pos - my_pos)

    -- Decidir comportamiento basado en distancia al jugador
    if distance_to_player < self.detection_range then
        self.state = "chase"
        chase_player(self, player_pos, my_pos, dt)
    else
        self.state = "patrol"
        patrol_behavior(self, my_pos, dt)
    end

    -- Actualizar animación
    update_enemy_animation(self)
end

function patrol_behavior(self, pos, dt)
    -- Patrullar entre dos puntos
    local next_x = pos.x + self.direction * self.move_speed * dt

    if next_x > self.patrol_right then
        self.direction = -1
        next_x = self.patrol_right
    elseif next_x < self.patrol_left then
        self.direction = 1
        next_x = self.patrol_left
    end

    pos.x = next_x
    go.set_position(pos)
end

function chase_player(self, player_pos, my_pos, dt)
    -- Moverse hacia el jugador
    local direction_to_player = player_pos.x > my_pos.x and 1 or -1
    self.direction = direction_to_player

    local next_x = my_pos.x + direction_to_player * self.move_speed * 1.5 * dt
    my_pos.x = next_x
    go.set_position(my_pos)
end

function update_enemy_animation(self)
    -- Voltear sprite según dirección
    local scale = go.get_scale()
    scale.x = self.direction > 0 and math.abs(scale.x) or -math.abs(scale.x)
    go.set_scale(scale)

    -- Cambiar animación según estado
    if self.state == "stunned" then
        sprite.play_flipbook("#sprite", "stunned")
    else
        sprite.play_flipbook("#sprite", "walk")
    end
end

function on_message(self, message_id, message, sender)
    if message_id == hash("stomped") then
        get_stomped(self)
    elseif message_id == hash("contact_point_response") then
        handle_collision(self, message)
    end
end

function get_stomped(self)
    self.health = self.health - 1

    if self.health <= 0 then
        die(self)
    else
        self.state = "stunned"
        self.stunned_timer = 1.0

        -- Efecto visual de stun
        sprite.set_constant("#sprite", "tint", vmath.vector4(1, 1, 0.5, 1))
    end
end

function die(self)
    -- Efecto de muerte
    create_death_effect(go.get_position())
    msg.post("#death_sound", "play_sound")

    -- Dar puntos al jugador
    msg.post("/level_manager", "enemy_killed", {points = 100})

    go.delete()
end

function handle_collision(self, message)
    if message.other_group == hash("ground") then
        -- Voltear dirección si choca con pared
        if math.abs(message.normal.x) > 0.7 then
            self.direction = -self.direction
        end
    end
end

Enemigo Volador

-- enemies/flying_enemy.script
go.property("fly_height", 150)
go.property("fly_speed", 80)
go.property("dive_speed", 200)

function init(self)
    self.state = "fly"  -- "fly", "dive", "return"
    self.start_pos = go.get_position()
    self.target_height = self.start_pos.y + self.fly_height
    self.direction = 1

    self.dive_start_pos = nil
    self.dive_target_pos = nil

    print("Flying enemy inicializado")
end

function update(self, dt)
    local player_pos = go.get_position("/level/player")
    local my_pos = go.get_position()

    if self.state == "fly" then
        fly_behavior(self, player_pos, my_pos, dt)
    elseif self.state == "dive" then
        dive_behavior(self, my_pos, dt)
    elseif self.state == "return" then
        return_behavior(self, my_pos, dt)
    end
end

function fly_behavior(self, player_pos, my_pos, dt)
    -- Volar horizontalmente a altura fija
    my_pos.x = my_pos.x + self.direction * self.fly_speed * dt
    my_pos.y = self.target_height

    -- Detectar si está sobre el jugador para atacar
    local horizontal_distance = math.abs(player_pos.x - my_pos.x)
    if horizontal_distance < 30 and player_pos.y < my_pos.y - 50 then
        start_dive(self, player_pos)
    end

    go.set_position(my_pos)
end

function start_dive(self, player_pos)
    self.state = "dive"
    self.dive_start_pos = go.get_position()
    self.dive_target_pos = player_pos

    print("Flying enemy iniciando picada")
end

function dive_behavior(self, my_pos, dt)
    -- Moverse hacia el objetivo de la picada
    local direction = vmath.normalize(self.dive_target_pos - my_pos)
    my_pos = my_pos + direction * self.dive_speed * dt

    go.set_position(my_pos)

    -- Si llegó al suelo o muy cerca del objetivo, regresar
    if my_pos.y <= self.dive_target_pos.y + 20 then
        self.state = "return"
    end
end

function return_behavior(self, my_pos, dt)
    -- Regresar a posición de vuelo
    local direction = vmath.normalize(self.dive_start_pos - my_pos)
    my_pos = my_pos + direction * self.fly_speed * dt

    go.set_position(my_pos)

    -- Si regresó a la altura de vuelo, continuar patrullaje
    if my_pos.y >= self.target_height - 10 then
        self.state = "fly"
    end
end

Parte 4: Collectibles y Power-ups

Sistema de Gemas

-- collectibles/gem.script
go.property("gem_value", 10)
go.property("bounce_height", 20)

function init(self)
    self.collected = false
    self.bounce_timer = 0

    -- Animación de flotación
    animate_floating(self)

    print("Gema creada con valor:", self.gem_value)
end

function update(self, dt)
    if not self.collected then
        self.bounce_timer = self.bounce_timer + dt
        local bounce_offset = math.sin(self.bounce_timer * 3) * self.bounce_height
        local base_pos = go.get_position()
        base_pos.y = base_pos.y + bounce_offset * 0.1
        go.set_position(base_pos)
    end
end

function animate_floating(self)
    -- Rotación continua
    go.animate(".", "rotation.z", go.PLAYBACK_LOOP_FORWARD,
              math.rad(360), go.EASING_LINEAR, 2.0)

    -- Escala pulsante
    go.animate(".", "scale", go.PLAYBACK_LOOP_PINGPONG,
              vmath.vector3(1.2, 1.2, 1), go.EASING_INOUTQUAD, 1.0)
end

function on_message(self, message_id, message, sender)
    if message_id == hash("trigger_response") then
        if message.enter and message.other_group == hash("player") then
            collect_gem(self)
        end
    end
end

function collect_gem(self)
    if self.collected then return end

    self.collected = true

    -- Notificar al level manager
    msg.post("/level_manager", "gem_collected", {value = self.gem_value})

    -- Efecto visual de recolección
    create_collect_effect(self)

    -- Sonido
    msg.post("#collect_sound", "play_sound")

    -- Animación de recolección hacia UI
    animate_to_ui(self)
end

function create_collect_effect(self)
    local pos = go.get_position()

    -- Partículas brillantes
    factory.create("/effects#sparkle_factory", pos)

    -- Texto flotante con puntos
    factory.create("/effects#score_text_factory", pos, nil, {
        text = "+" .. self.gem_value,
        color = vmath.vector4(1, 1, 0, 1)
    })
end

function animate_to_ui(self)
    -- Animar hacia el contador de gemas en la UI
    local ui_pos = vmath.vector3(100, 580, 0)  -- Posición del contador

    go.animate(".", "position", go.PLAYBACK_ONCE_FORWARD,
              ui_pos, go.EASING_OUTQUAD, 0.8)

    go.animate(".", "scale", go.PLAYBACK_ONCE_FORWARD,
              vmath.vector3(0.3, 0.3, 1), go.EASING_OUTQUAD, 0.8,
              0, function() go.delete() end)
end

Power-ups Temporales

-- collectibles/powerup.script
go.property("powerup_type", "speed")  -- "speed", "jump", "invincible"
go.property("duration", 10.0)

local POWERUP_CONFIGS = {
    speed = {
        color = vmath.vector4(0, 1, 0, 1),
        effect_multiplier = 1.5
    },
    jump = {
        color = vmath.vector4(0, 0.5, 1, 1),
        effect_multiplier = 1.3
    },
    invincible = {
        color = vmath.vector4(1, 1, 0, 1),
        effect_multiplier = 1.0
    }
}

function init(self)
    self.collected = false
    local config = POWERUP_CONFIGS[self.powerup_type]

    -- Configurar apariencia según tipo
    sprite.set_constant("#sprite", "tint", config.color)

    -- Animación de power-up
    animate_powerup(self)

    print("Power-up creado:", self.powerup_type)
end

function animate_powerup(self)
    -- Rotación y escala dinámica
    go.animate(".", "rotation.z", go.PLAYBACK_LOOP_FORWARD,
              math.rad(180), go.EASING_LINEAR, 1.0)

    go.animate(".", "scale", go.PLAYBACK_LOOP_PINGPONG,
              vmath.vector3(1.3, 1.3, 1), go.EASING_INOUTSIN, 0.8)
end

function on_message(self, message_id, message, sender)
    if message_id == hash("trigger_response") then
        if message.enter and message.other_group == hash("player") then
            activate_powerup(self)
        end
    end
end

function activate_powerup(self)
    if self.collected then return end

    self.collected = true

    -- Aplicar power-up al jugador
    msg.post("/level/player", "apply_powerup", {
        type = self.powerup_type,
        duration = self.duration,
        multiplier = POWERUP_CONFIGS[self.powerup_type].effect_multiplier
    })

    -- Efecto visual
    create_powerup_effect(self)

    -- Sonido especial
    msg.post("#powerup_sound", "play_sound")

    go.delete()
end

function create_powerup_effect(self)
    local pos = go.get_position()
    local config = POWERUP_CONFIGS[self.powerup_type]

    -- Efecto de explosión colorida
    factory.create("/effects#powerup_explosion_factory", pos, nil, {
        color = config.color,
        particle_count = 20
    })
end

Parte 5: Sistema de Checkpoints

Checkpoint Game Object

-- levels/checkpoint.script
function init(self)
    self.activated = false
    self.animation_playing = false

    print("Checkpoint creado")
end

function on_message(self, message_id, message, sender)
    if message_id == hash("trigger_response") then
        if message.enter and message.other_group == hash("player") then
            if not self.activated then
                activate_checkpoint(self)
            end
        end
    elseif message_id == hash("activate_visual") then
        play_activation_animation(self)
    end
end

function activate_checkpoint(self)
    self.activated = true

    -- Notificar al level manager
    msg.post("/level_manager", "checkpoint_activated", {checkpoint_id = go.get_id()})

    -- Activar animación visual
    play_activation_animation(self)
end

function play_activation_animation(self)
    if self.animation_playing then return end

    self.animation_playing = true

    -- Cambiar a sprite activado
    sprite.play_flipbook("#sprite", "checkpoint_active")

    -- Efecto de luz
    go.animate(".", "scale", go.PLAYBACK_ONCE_FORWARD,
              vmath.vector3(1.5, 1.5, 1), go.EASING_OUTBACK, 0.3,
              0, function()
                  go.animate(".", "scale", go.PLAYBACK_ONCE_FORWARD,
                            vmath.vector3(1, 1, 1), go.EASING_OUTQUAD, 0.2,
                            0, function()
                                self.animation_playing = false
                            end)
              end)

    -- Efecto de partículas
    local pos = go.get_position()
    factory.create("/effects#checkpoint_factory", pos)
end

Ejercicios de Expansión

Ejercicio 1: Mecánicas Avanzadas

Ejercicio 2: Elementos de Nivel

Ejercicio 3: Boss Fight

Próximos Pasos

En la siguiente lección aprenderemos a crear efectos visuales y partículas profesionales que darán vida y polish a nuestros juegos.


⬅️ Anterior: GUI y Menús | Siguiente: Efectos y Partículas ➡️

¡Excelente! Ahora tienes un juego de plataformas completo con todas las mecánicas esenciales del género. Este proyecto te servirá como base sólida para crear platformers más complejos.