Animaciones Avanzadas con Spine y Esqueletos
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
spine.play_anim()- Reproducir animaciónspine.set_skin()- Cambiar skin del personajespine.get_bones()- Obtener lista de huesosspine.set_ik_target()- Configurar IK (Inverse Kinematics)spine.set_attachment()- Cambiar attachment de slot
Herramientas Recomendadas
- Spine Pro - Editor principal de animaciones
- DragonBones - Alternativa gratuita a Spine
- Spriter - Editor de animaciones 2D
Links Útiles
🎯 Ejercicios Propuestos
-
Character Customization: Crea un sistema completo de customización que permita cambiar colores, accesorios y partes del cuerpo.
-
Combat System: Implementa un sistema de combate con combos, counters y special moves.
-
Facial Animation: Crea un sistema de animaciones faciales para diálogos expresivos.
-
Ragdoll Physics: Implementa un sistema que cambie de animación a física ragdoll al morir.
-
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.