← Volver al listado de tecnologías

Animaciones Avanzadas con Spine y Esqueletos

Por: Artiko
defoldspineanimacionesesqueletos2d

Animaciones Avanzadas con Spine y Esqueletos

Spine es la herramienta líder en la industria para animaciones 2D profesionales con esqueletos. En esta lección aprenderás a integrar y controlar animaciones Spine complejas en Defold.

🦴 Fundamentos de Spine en Defold

Configuración Inicial

-- spine_character.script
function init(self)
    self.spine_url = msg.url("#spinemodel")
    self.current_animation = ""
    self.animation_state = "idle"

    -- Configuración inicial
    spine.play_anim(self.spine_url, "idle", go.PLAYBACK_LOOP_FORWARD)

    -- Establecer velocidad de animación
    spine.set_playback_rate(self.spine_url, 1.0)

    -- Configurar eventos de animación
    spine.set_skin(self.spine_url, "default")
end

-- Cambio de animación suave
local function transition_to_animation(self, new_anim, blend_time)
    if self.current_animation ~= new_anim then
        local current_time = spine.get_cursor(self.spine_url)

        -- Aplicar blending
        spine.play_anim(self.spine_url, new_anim, go.PLAYBACK_LOOP_FORWARD,
                       {blend_duration = blend_time or 0.1})

        self.current_animation = new_anim
    end
end

Sistema de Estados de Animación

-- animation_state_machine.script
local STATES = {
    IDLE = {
        animation = "idle",
        transitions = {"walk", "jump", "attack", "hurt"}
    },
    WALK = {
        animation = "walk",
        transitions = {"idle", "run", "jump"}
    },
    RUN = {
        animation = "run",
        transitions = {"walk", "idle", "jump", "slide"}
    },
    JUMP = {
        animation = "jump",
        transitions = {"fall", "double_jump"}
    },
    ATTACK = {
        animation = "attack",
        transitions = {"idle", "combo_2"},
        duration = 0.5
    }
}

function init(self)
    self.current_state = "IDLE"
    self.state_timer = 0
    self.spine_url = msg.url("#spinemodel")

    -- Iniciar con estado idle
    self:change_state("IDLE")
end

local function change_state(self, new_state)
    local current = STATES[self.current_state]
    local target = STATES[new_state]

    -- Verificar si la transición es válida
    if not current or not target then return false end

    local can_transition = false
    for _, allowed_state in ipairs(current.transitions) do
        if allowed_state == new_state:lower() then
            can_transition = true
            break
        end
    end

    if can_transition then
        self.current_state = new_state
        self.state_timer = 0

        -- Reproducir animación con blending
        spine.play_anim(self.spine_url, target.animation,
                       go.PLAYBACK_LOOP_FORWARD, {blend_duration = 0.15})

        return true
    end

    return false
end

function update(self, dt)
    self.state_timer = self.state_timer + dt

    -- Auto-transiciones basadas en tiempo
    local current = STATES[self.current_state]
    if current.duration and self.state_timer >= current.duration then
        if self.current_state == "ATTACK" then
            self:change_state("IDLE")
        end
    end
end

🎭 Sistema de Skins Dinámicas

Manager de Equipamiento

-- equipment_manager.script
local EQUIPMENT_SLOTS = {
    WEAPON = "weapon",
    ARMOR = "armor",
    HELMET = "helmet",
    BOOTS = "boots"
}

local EQUIPMENT_ATTACHMENTS = {
    sword = { bone = "hand_r", skin = "sword_basic" },
    bow = { bone = "hand_l", skin = "bow_wooden" },
    shield = { bone = "hand_l", skin = "shield_iron" },
    cape = { bone = "spine", skin = "cape_red" }
}

function init(self)
    self.spine_url = msg.url("#spinemodel")
    self.equipped_items = {}
    self.current_skin = "base"
end

local function equip_item(self, item_id, slot)
    -- Remover item anterior si existe
    if self.equipped_items[slot] then
        self:unequip_item(slot)
    end

    local equipment = EQUIPMENT_ATTACHMENTS[item_id]
    if equipment then
        -- Cambiar skin para mostrar el item
        spine.set_attachment(self.spine_url, equipment.slot, equipment.skin)

        -- Si es un arma, attachear al hueso correspondiente
        if equipment.bone then
            spine.set_ik_target_position(self.spine_url, equipment.bone,
                                       vmath.vector3(0, 0, 0))
        end

        self.equipped_items[slot] = item_id

        -- Actualizar animaciones si es necesario
        self:update_combat_animations(item_id)
    end
end

local function update_combat_animations(self, weapon_type)
    if weapon_type == "sword" then
        spine.set_animation_mix(self.spine_url, "idle", "sword_attack", 0.1)
        spine.set_animation_mix(self.spine_url, "sword_attack", "idle", 0.2)
    elseif weapon_type == "bow" then
        spine.set_animation_mix(self.spine_url, "idle", "bow_draw", 0.15)
        spine.set_animation_mix(self.spine_url, "bow_draw", "bow_shoot", 0.05)
    end
end

Sistema de Customización de Personajes

-- character_customizer.script
local CUSTOMIZATION_OPTIONS = {
    hair_color = {"black", "brown", "blonde", "red", "white"},
    skin_tone = {"light", "medium", "dark", "pale"},
    eye_color = {"blue", "brown", "green", "hazel"},
    body_type = {"slim", "normal", "muscular", "heavy"}
}

local function apply_customization(self, options)
    -- Construir nombre de skin basado en opciones
    local skin_name = string.format("%s_%s_%s_%s",
        options.body_type or "normal",
        options.skin_tone or "medium",
        options.hair_color or "brown",
        options.eye_color or "brown"
    )

    -- Verificar si la skin existe, sino usar default
    local available_skins = spine.get_skins(self.spine_url)
    local skin_exists = false

    for _, skin in ipairs(available_skins) do
        if skin == skin_name then
            skin_exists = true
            break
        end
    end

    if skin_exists then
        spine.set_skin(self.spine_url, skin_name)
    else
        -- Aplicar combinación de skins por partes
        spine.set_skin(self.spine_url, "base")
        spine.set_attachment(self.spine_url, "hair", "hair_" .. options.hair_color)
        spine.set_attachment(self.spine_url, "body", "body_" .. options.skin_tone)
    end
end

🎮 Control Avanzado de Animaciones

Sistema de Blend Trees

-- blend_tree_controller.script
local function create_locomotion_blend_tree(self)
    return {
        type = "2D_blend",
        parameter_x = "speed",
        parameter_y = "direction",
        clips = {
            {pos = {0, 0}, animation = "idle"},
            {pos = {1, 0}, animation = "walk_forward"},
            {pos = {2, 0}, animation = "run_forward"},
            {pos = {0, 1}, animation = "walk_right"},
            {pos = {0, -1}, animation = "walk_left"},
            {pos = {-1, 0}, animation = "walk_backward"}
        }
    }
end

local function evaluate_blend_tree(self, blend_tree, params)
    if blend_tree.type == "2D_blend" then
        local x = params[blend_tree.parameter_x] or 0
        local y = params[blend_tree.parameter_y] or 0

        -- Encontrar los 3 clips más cercanos para triangulación
        local distances = {}
        for i, clip in ipairs(blend_tree.clips) do
            local dist = math.sqrt((clip.pos[1] - x)^2 + (clip.pos[2] - y)^2)
            table.insert(distances, {index = i, distance = dist, clip = clip})
        end

        -- Ordenar por distancia
        table.sort(distances, function(a, b) return a.distance < b.distance end)

        -- Calcular pesos para los 3 clips más cercanos
        local total_weight = 0
        local weights = {}

        for i = 1, math.min(3, #distances) do
            local weight = 1.0 / (distances[i].distance + 0.001) -- Evitar división por 0
            weights[distances[i].index] = weight
            total_weight = total_weight + weight
        end

        -- Normalizar pesos
        for index, weight in pairs(weights) do
            weights[index] = weight / total_weight
        end

        return weights
    end
end

function update(self, dt)
    -- Obtener parámetros de movimiento
    local velocity = go.get(".", "velocity") or vmath.vector3()
    local speed = vmath.length(velocity)
    local direction = math.atan2(velocity.y, velocity.x)

    -- Evaluar blend tree
    local params = {speed = speed, direction = direction}
    local weights = evaluate_blend_tree(self, self.locomotion_tree, params)

    -- Aplicar blending de animaciones
    self:apply_animation_blending(weights)
end

Animaciones Procedurales

-- procedural_animation.script
local function apply_look_at_ik(self, target_position)
    local head_bone = "head"
    local neck_bone = "neck"

    -- Calcular ángulo hacia el objetivo
    local char_pos = go.get_position()
    local direction = vmath.normalize(target_position - char_pos)
    local angle = math.atan2(direction.y, direction.x)

    -- Limitar rango de rotación del cuello/cabeza
    local max_head_angle = math.rad(45) -- 45 grados máximo
    local clamped_angle = math.max(-max_head_angle,
                                 math.min(max_head_angle, angle))

    -- Aplicar IK al hueso de la cabeza
    spine.set_ik_target(self.spine_url, head_bone,
                       vmath.vector3(math.cos(clamped_angle) * 100,
                                   math.sin(clamped_angle) * 100, 0))
end

local function apply_foot_ik(self, ground_height_left, ground_height_right)
    local foot_l_bone = "foot_l"
    local foot_r_bone = "foot_r"

    -- Ajustar posición de pies según altura del terreno
    local foot_l_pos = spine.get_bone_position(self.spine_url, foot_l_bone)
    local foot_r_pos = spine.get_bone_position(self.spine_url, foot_r_bone)

    foot_l_pos.y = ground_height_left
    foot_r_pos.y = ground_height_right

    spine.set_ik_target_position(self.spine_url, foot_l_bone, foot_l_pos)
    spine.set_ik_target_position(self.spine_url, foot_r_bone, foot_r_pos)
end

local function apply_breathing_animation(self, time)
    local chest_bone = "chest"
    local breath_intensity = 0.05
    local breath_speed = 2.0

    -- Crear movimiento de respiración sinusoidal
    local breath_offset = math.sin(time * breath_speed) * breath_intensity

    local chest_pos = spine.get_bone_position(self.spine_url, chest_bone)
    chest_pos.y = chest_pos.y + breath_offset

    spine.set_bone_position(self.spine_url, chest_bone, chest_pos)
end

🎯 Sistema de Eventos de Animación

Event Handler Avanzado

-- animation_events.script
local ANIMATION_EVENTS = {
    footstep = function(self, event_data)
        -- Reproducir sonido de paso
        sound.play("#footstep_sound")

        -- Crear partículas de polvo
        particlefx.play("#dust_particles")

        -- Vibración en móvil
        if sys.get_sys_info().system_name == "Android" or
           sys.get_sys_info().system_name == "iPhone OS" then
            vibrate.trigger(50) -- 50ms de vibración
        end
    end,

    attack_hit = function(self, event_data)
        -- Activar hitbox
        self.attack_active = true
        self.attack_timer = 0.1 -- Activo por 100ms

        -- Efecto visual
        go.animate(".", "tint.w", go.PLAYBACK_ONCE_PINGPONG, 0.5, go.EASING_OUTQUAD, 0.1)
    end,

    spell_cast = function(self, event_data)
        -- Crear proyectil
        local projectile_pos = go.get_position() + vmath.vector3(50, 20, 0)
        factory.create("#projectile_factory", projectile_pos)

        -- Efecto de cámara
        msg.post("main:/camera", "shake", {intensity = 0.3, duration = 0.2})
    end,

    combo_window = function(self, event_data)
        -- Abrir ventana de combo
        self.combo_window_open = true
        self.combo_timer = 0.5 -- 500ms para hacer combo
    end
}

function on_message(self, message_id, message, sender)
    if message_id == hash("spine_event") then
        local event_handler = ANIMATION_EVENTS[message.event_id]
        if event_handler then
            event_handler(self, message)
        end
    elseif message_id == hash("spine_animation_done") then
        -- Manejar fin de animación
        self:on_animation_complete(message.animation_id)
    end
end

Timeline de Eventos Complejos

-- timeline_controller.script
local function create_cutscene_timeline(self)
    return {
        {time = 0.0, event = "start_dialogue", data = {speaker = "hero", text = "¡Por fin llegamos!"}},
        {time = 2.0, event = "play_animation", data = {character = "hero", anim = "point"}},
        {time = 2.5, event = "camera_move", data = {target = "castle", duration = 1.5}},
        {time = 4.0, event = "play_animation", data = {character = "companion", anim = "nod"}},
        {time = 4.2, event = "start_dialogue", data = {speaker = "companion", text = "Sí, el castillo del Rey Demonio."}},
        {time = 6.5, event = "play_sound", data = {sound = "ominous_music"}},
        {time = 7.0, event = "screen_fade", data = {color = "black", duration = 2.0}}
    }
end

function init(self)
    self.timeline = create_cutscene_timeline(self)
    self.timeline_time = 0
    self.timeline_index = 1
    self.playing = false
end

function update(self, dt)
    if not self.playing then return end

    self.timeline_time = self.timeline_time + dt

    -- Procesar eventos en el timeline
    while self.timeline_index <= #self.timeline do
        local event = self.timeline[self.timeline_index]

        if self.timeline_time >= event.time then
            self:execute_timeline_event(event)
            self.timeline_index = self.timeline_index + 1
        else
            break
        end
    end

    -- Verificar si terminó el timeline
    if self.timeline_index > #self.timeline then
        self.playing = false
        msg.post(".", "timeline_complete")
    end
end

🎨 Efectos Visuales Avanzados

Deformación de Mesh en Tiempo Real

-- mesh_deformation.script
local function apply_wind_deformation(self, wind_strength, wind_direction)
    local cape_bones = {"cape_1", "cape_2", "cape_3", "cape_4"}

    for i, bone_name in ipairs(cape_bones) do
        local base_pos = spine.get_bone_position(self.spine_url, bone_name)

        -- Aplicar deformación basada en el viento
        local deformation = wind_strength * (i / #cape_bones) -- Más efecto en la punta
        local wind_offset = vmath.vector3(
            math.cos(wind_direction) * deformation,
            math.sin(wind_direction) * deformation * 0.5,
            0
        )

        spine.set_bone_position(self.spine_url, bone_name, base_pos + wind_offset)
    end
end

local function apply_impact_deformation(self, impact_point, force)
    local all_bones = spine.get_bones(self.spine_url)

    for _, bone_name in ipairs(all_bones) do
        local bone_pos = spine.get_bone_position(self.spine_url, bone_name)
        local distance = vmath.length(bone_pos - impact_point)

        if distance < 100 then -- Radio de efecto
            local impact_force = force * (1 - distance / 100) -- Fuerza decae con distancia
            local direction = vmath.normalize(bone_pos - impact_point)

            -- Aplicar deformación temporal
            local deformation = direction * impact_force
            go.animate(self.spine_url, "bone_offset_" .. bone_name,
                      go.PLAYBACK_ONCE_PINGPONG, deformation,
                      go.EASING_OUTBOUNCE, 0.5)
        end
    end
end

Sistema de Materiales Dinámicos

-- material_controller.script
local MATERIAL_EFFECTS = {
    dissolve = {
        properties = {"dissolve_amount", "dissolve_color"},
        duration = 2.0
    },
    outline = {
        properties = {"outline_width", "outline_color"},
        duration = 0.5
    },
    damage_flash = {
        properties = {"flash_intensity"},
        duration = 0.2
    }
}

local function apply_material_effect(self, effect_name, params)
    local effect = MATERIAL_EFFECTS[effect_name]
    if not effect then return end

    if effect_name == "dissolve" then
        -- Efecto de disolución
        sprite.set_constant(self.spine_url, "dissolve_amount", 0)
        sprite.set_constant(self.spine_url, "dissolve_color", params.color or vmath.vector4(1, 0, 0, 1))

        go.animate(self.spine_url, "dissolve_amount", go.PLAYBACK_ONCE_FORWARD,
                  1.0, go.EASING_OUTSINE, effect.duration)

    elseif effect_name == "outline" then
        -- Efecto de contorno
        sprite.set_constant(self.spine_url, "outline_width", 0)
        sprite.set_constant(self.spine_url, "outline_color", params.color or vmath.vector4(1, 1, 1, 1))

        go.animate(self.spine_url, "outline_width", go.PLAYBACK_ONCE_PINGPONG,
                  params.width or 2.0, go.EASING_OUTQUAD, effect.duration)

    elseif effect_name == "damage_flash" then
        -- Flash de daño
        sprite.set_constant(self.spine_url, "flash_intensity", 0)
        go.animate(self.spine_url, "flash_intensity", go.PLAYBACK_ONCE_PINGPONG,
                  1.0, go.EASING_OUTQUAD, effect.duration)
    end
end

🎮 Proyecto Práctico: Sistema de Combate Avanzado

Vamos a crear un sistema de combate completo usando animaciones Spine:

1. Combat Controller

-- combat_controller.script
local COMBAT_MOVES = {
    light_attack = {
        animation = "attack_light",
        damage = 10,
        startup = 0.1,
        active = 0.2,
        recovery = 0.3,
        hitbox = {offset = vmath.vector3(40, 0, 0), size = vmath.vector3(60, 80, 0)}
    },
    heavy_attack = {
        animation = "attack_heavy",
        damage = 25,
        startup = 0.3,
        active = 0.3,
        recovery = 0.6,
        hitbox = {offset = vmath.vector3(50, 0, 0), size = vmath.vector3(80, 100, 0)}
    },
    combo_1 = {
        animation = "combo_1",
        damage = 8,
        startup = 0.08,
        active = 0.15,
        recovery = 0.2,
        next_combo = "combo_2"
    }
}

function init(self)
    self.spine_url = msg.url("#spinemodel")
    self.current_move = nil
    self.move_timer = 0
    self.move_phase = "idle" -- idle, startup, active, recovery
    self.combo_count = 0
    self.can_combo = false
end

local function execute_combat_move(self, move_name)
    local move = COMBAT_MOVES[move_name]
    if not move then return false end

    -- Verificar si podemos ejecutar el movimiento
    if self.move_phase ~= "idle" and not self.can_combo then
        return false
    end

    self.current_move = move
    self.move_timer = 0
    self.move_phase = "startup"
    self.can_combo = false

    -- Reproducir animación
    spine.play_anim(self.spine_url, move.animation, go.PLAYBACK_ONCE_FORWARD)

    return true
end

function update(self, dt)
    if not self.current_move then return end

    self.move_timer = self.move_timer + dt
    local move = self.current_move

    -- Máquina de estados del movimiento
    if self.move_phase == "startup" then
        if self.move_timer >= move.startup then
            self.move_phase = "active"
            self:activate_hitbox(move.hitbox)
        end

    elseif self.move_phase == "active" then
        if self.move_timer >= move.startup + move.active then
            self.move_phase = "recovery"
            self:deactivate_hitbox()

            -- Abrir ventana de combo si existe
            if move.next_combo then
                self.can_combo = true
            end
        end

    elseif self.move_phase == "recovery" then
        if self.move_timer >= move.startup + move.active + move.recovery then
            self.move_phase = "idle"
            self.current_move = nil
            self.can_combo = false
        end
    end
end

2. Hitbox System

-- hitbox_system.script
function init(self)
    self.active_hitboxes = {}
    self.hit_targets = {} -- Para evitar hits múltiples
end

local function create_hitbox(self, hitbox_data, damage, owner)
    local hitbox_id = #self.active_hitboxes + 1

    local hitbox = {
        id = hitbox_id,
        position = go.get_position(owner) + hitbox_data.offset,
        size = hitbox_data.size,
        damage = damage,
        owner = owner,
        lifetime = 0.1, -- Hitbox activo por 100ms
        hit_targets = {}
    }

    table.insert(self.active_hitboxes, hitbox)
    return hitbox_id
end

function update(self, dt)
    for i = #self.active_hitboxes, 1, -1 do
        local hitbox = self.active_hitboxes[i]
        hitbox.lifetime = hitbox.lifetime - dt

        if hitbox.lifetime <= 0 then
            table.remove(self.active_hitboxes, i)
        else
            -- Verificar colisiones
            self:check_hitbox_collisions(hitbox)
        end
    end
end

local function check_hitbox_collisions(self, hitbox)
    -- Obtener todos los enemigos en rango
    local enemies = self:get_enemies_in_range(hitbox.position, hitbox.size)

    for _, enemy in ipairs(enemies) do
        if not hitbox.hit_targets[enemy.id] then
            -- Aplicar daño
            msg.post(enemy.url, "take_damage", {
                damage = hitbox.damage,
                source = hitbox.owner,
                knockback = vmath.vector3(50, 20, 0)
            })

            hitbox.hit_targets[enemy.id] = true

            -- Efectos visuales
            self:create_hit_effect(enemy.position)
        end
    end
end

📚 Recursos y Referencias

API de Spine en Defold

Herramientas Recomendadas

🎯 Ejercicios Propuestos

  1. Character Customization: Crea un sistema completo de customización que permita cambiar colores, accesorios y partes del cuerpo.

  2. Combat System: Implementa un sistema de combate con combos, counters y special moves.

  3. Facial Animation: Crea un sistema de animaciones faciales para diálogos expresivos.

  4. Ragdoll Physics: Implementa un sistema que cambie de animación a física ragdoll al morir.

  5. Lip Sync: Crea un sistema de sincronización labial automática basada en audio.

Las animaciones Spine son el corazón de cualquier juego 2D profesional. Dominar estas técnicas te permitirá crear personajes que cobren vida y cautiven a los jugadores con movimientos fluidos y expresivos.