Movimiento y ataque del personaje en minijuegos Cocos: de nodos a animaciones

Tu personaje está en el centro de la pantalla; pulsas las flechas de dirección y no se mueve. Abres el código y ves que la lógica de movimiento, la reproducción de animaciones y la evaluación de estados están todas amontonadas en una sola función update. Cambias la animación y olvidas el estado; cambias el estado y olvidas la velocidad. Al final ni tú sabes qué debería estar haciendo el personaje.
La separación de nodos suena abstracta, pero en la práctica consiste en gestionar movimiento y animación por separado: el nodo Player se encarga del desplazamiento horizontal y el nodo Body del salto vertical y las animaciones de ataque, cada uno con su lógica independiente. Este artículo te guía desde la arquitectura de nodos hasta la configuración del componente de animación, la máquina de estados, la elección del esquema de entrada y un conjunto completo de código para un personaje de lucha en vista lateral.
Diseño de la arquitectura de nodos — movimiento horizontal + animación vertical superpuesta
El tutorial oficial de Cocos Creator 3.7 incluye un diseño que muchos pasan por alto: separar los nodos Player y Body. Mucha gente (yo incluido al principio) piensa que no hace falta: ¿no es más simple un solo nodo para movimiento, animación y detección de colisiones?
El problema es que el ritmo de la animación y el del movimiento no van a la par. Imagina que tu personaje salta: avanza a velocidad constante en horizontal mientras en vertical describe una parábola. Con un solo nodo, update debe calcular desplazamiento horizontal y altura del salto a la vez; el código se enreda rápido. Cuando añades animación de ataque, efecto de impacto o incluso rotación y escala al morir, la lógica de un único nodo se descontrola.
La ventaja de separar nodos: el movimiento por un lado, la animación por otro. Player solo modifica la coordenada x; Body solo la y del salto y el cambio de fotogramas. Cada uno hace su parte sin pisarse.
Esquema de la estructura de nodos:
Player(nodo raíz)
├── PlayerControl.ts(lógica de movimiento)
└── Body(nodo hijo)
├── Sprite(imagen del personaje)
└── Componente Animation
Ejemplo de código — control de movimiento del nodo Player:
// PlayerControl.ts - Script del nodo Player
@property(CCFloat)
speed: number = 200; // Velocidad de movimiento
private moveDir: Vec2 = new Vec2(0, 0);
update(dt: number) {
if (this.moveDir.mag() > 0.5) {
const vx = this.moveDir.x * this.speed;
this.node.x += vx * dt; // Solo modifica la posición horizontal
}
}
// Llamada externa para establecer la dirección
setMoveDir(dir: Vec2) {
this.moveDir = dir;
}
Ejemplo de código — control de animación del nodo Body:
// BodyControl.ts - Script del nodo Body
@property(Animation)
animation: Animation = null;
jump(height: number = 100) {
// Animación de salto: desplazamiento vertical + fotogramas jump
this.node.runAction(cc.jumpBy(1.0, 0, 0, height, 1));
this.animation.play('jump');
}
attack() {
this.animation.play('attack');
// Vuelve automáticamente a idle al terminar el ataque
this.scheduleOnce(() => {
this.animation.play('idle');
}, 0.5);
}
Así, la lógica de movimiento vive en Player y la de animación en Body. Cambiar la altura del salto no obliga a tocar el código de movimiento; cambiar la velocidad no obliga a buscar en el script de animación. El mantenimiento resulta mucho más sencillo.
Configuración del componente de animación — Animation + AnimationClip
El componente Animation es un módulo integrado de Cocos Creator, pero quizá no hayas notado algunas propiedades clave: defaultClip, el array clips y el fácil de olvidar playOnLoad.
Propiedades principales del componente Animation:
defaultClip: animación que se reproduce por defecto (normalmente reposo)clips: array de recursos de animación (AnimationClip de reposo, caminar, atacar, etc.)currentClip: animación en reproducción (estado en tiempo de ejecución)
Los pasos de configuración son sencillos:
- Añade el componente Animation al nodo Body
- Arrastra los recursos AnimationClip al array clips
- Establece defaultClip como la animación de reposo
- Activa playOnLoad (para que el personaje empiece en reposo)
Ejemplo de código — configuración del componente de animación:
// PlayerAnimation.ts - Script de control de animación
@property(Animation)
animation: Animation = null;
@property([AnimationClip])
clips: AnimationClip[] = []; // Reposo, caminar, atacar, saltar
onLoad() {
this.animation.clips = this.clips;
this.animation.defaultClip = this.clips[0]; // Reposo por defecto
this.animation.play();
}
playIdle() { this.animation.play('idle'); }
playMove() { this.animation.play('move'); }
playAttack() {
this.animation.play('attack');
this.scheduleOnce(() => { this.playIdle(); }, 0.5);
}
Un detalle: al cambiar de animación conviene hacer stop() antes de play(), sobre todo en clips de un solo uso como el ataque. Si no, cuando el personaje camina y pulsas ataque, la animación de ataque puede competir con la de caminar: en pantalla parece que ataca mientras se desliza, algo bastante raro.
Forma correcta de cambiar de animación:
switchAnimation(name: string) {
if (this.animation.currentClip?.name !== name) {
this.animation.stop();
this.animation.play(name);
}
}
Con el componente configurado, el siguiente paso es la máquina de estados: que los cambios de animación sigan reglas y no queden repartidos como if (velocidad>0) play('move') por todo el código.
Máquina de estados de animación — reposo → movimiento → ataque → reposo
La idea central de la máquina de estados: el personaje solo puede estar en un estado a la vez (idle, move, attack), con condiciones de transición claras entre ellos. No es un cambio arbitrario, sino con reglas definidas.
Al principio también pensé que la máquina de estados era sobreingeniería: ¿no basta con play('attack')? Luego añadí salto, después estado de herido, y las condiciones de transición quedaron repartidas: en el script de movimiento, en el de animación y en el de entrada. Cambias una condición y olvidas actualizar las otras dos; el comportamiento del personaje empieza a fallar.
Diagrama de flujo de estados:
[Entry] → [idle]
↓ speed>0
[move]
↓ attackKey
[attack] → [Exit] → [idle]
Tres estados: idle (reposo), move (movimiento), attack (ataque). Condiciones de transición:
- idle → move: velocidad mayor que 0
- move → idle: velocidad igual a 0
- cualquiera → attack: pulsación de tecla de ataque
- attack → idle: fin de la animación de ataque
Ejemplo de código — implementación de la máquina de estados:
// PlayerStateMachine.ts
enum State {
Idle = 'idle',
Move = 'move',
Attack = 'attack'
}
@property(Animation)
animation: Animation = null;
private currentState: State = State.Idle;
private isMoving: boolean = false;
private attackKeyPressed: boolean = false;
switchState(newState: State) {
if (this.currentState === newState) return; // Evita repetición
this.animation.stop();
this.animation.play(newState);
this.currentState = newState;
}
update(dt: number) {
// Evaluación del estado de movimiento
if (this.isMoving && this.currentState !== State.Move) {
this.switchState(State.Move);
} else if (!this.isMoving && this.currentState !== State.Idle) {
this.switchState(State.Idle);
}
// Estado de ataque (prioridad sobre movimiento)
if (this.attackKeyPressed && this.currentState !== State.Attack) {
this.switchState(State.Attack);
this.scheduleOnce(() => { this.switchState(State.Idle); }, 0.5);
}
}
setMoving(value: boolean) { this.isMoving = value; }
triggerAttack() {
this.attackKeyPressed = true;
this.scheduleOnce(() => { this.attackKeyPressed = false; }, 0.1);
}
Detalles clave de la máquina de estados:
- Prioridad de estados: el ataque tiene prioridad sobre el movimiento (no se mueve mientras ataca)
- Evitar cambios repetidos:
if (currentState === newState) returnevita reproducir el mismo estado dos veces - Callback del temporizador: al terminar el ataque vuelve a idle automáticamente, sin esperar a soltar la tecla
La máquina concentra la lógica de cambio de animación en un solo sitio; modificar una condición de transición implica un solo cambio. Si más adelante añades salto, herido o muerte, solo amplías el enum State y sus transiciones: mucho más extensible que if dispersos.
Comparación de esquemas de entrada — teclado vs táctil vs joystick virtual
Cada esquema encaja en escenarios distintos; elegir mal puede arruinar la sensación de control.
| Esquema | Escenario | Ventajas | Desventajas | Complejidad |
|---|---|---|---|---|
| Teclado | PC, fase de depuración | Simple, respuesta rápida | No usable en móvil | Baja |
| Táctil | Juegos de movimiento a pantalla completa (vuelo, endless runner) | Natural, sin UI que tape | Poca precisión direccional, toques accidentales | Media |
| Joystick virtual | ARPG, juegos de lucha | Dirección precisa, control 360° | Ocupa espacio en pantalla | Alta |
Entrada por teclado (opción preferida en PC)
Si tu minijuego corre sobre todo en PC, o estás en fase de depuración validando lógica, el teclado es lo más simple.
Ejemplo de código — entrada por teclado:
// KeyboardInput.ts
private moveDir: Vec2 = new Vec2(0, 0);
onLoad() {
input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
}
onKeyDown(event: EventKeyboard) {
switch (event.keyCode) {
case KeyCode.KEY_A:
case KeyCode.ARROW_LEFT:
this.moveDir.x = -1; break;
case KeyCode.KEY_D:
case KeyCode.ARROW_RIGHT:
this.moveDir.x = 1; break;
case KeyCode.KEY_J:
this.playerStateMachine.triggerAttack(); break;
}
}
onKeyUp(event: EventKeyboard) {
switch (event.keyCode) {
case KeyCode.KEY_A:
case KeyCode.ARROW_LEFT:
case KeyCode.KEY_D:
case KeyCode.ARROW_RIGHT:
this.moveDir.x = 0; break;
}
}
update(dt: number) {
this.playerControl.setMoveDir(this.moveDir);
this.playerStateMachine.setMoving(this.moveDir.mag() > 0.5);
}
Detalles de la entrada por teclado:
- Mapeo de teclas: A/D y flechas izquierda/derecha, para distintos hábitos de jugador
- Tecla de ataque: J (habitual en juegos de lucha)
- Sincronización de estado: actualiza dirección y máquina de estados cada fotograma
Joystick virtual (opción preferida en móvil)
Si el minijuego va en móvil y es de lucha lateral o ARPG, donde hace falta dirección precisa, el joystick virtual casi es obligatorio.
Puntos clave del joystick virtual:
- Límite de radio: el centro del stick no puede salir del círculo de fondo
- Normalización de dirección: devuelve un vector unitario
- Al soltar el toque: el stick vuelve al centro automáticamente
Ejemplo de código — joystick virtual:
// Joystick.ts
@property(Node)
joystickBg: Node = null;
@property(Node)
joystickHandle: Node = null;
@property(CCFloat)
radius: number = 100;
private moveDir: Vec2 = new Vec2(0, 0);
onLoad() {
input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this);
}
onTouchMove(event: EventTouch) {
const touchPos = event.getUILocation();
const localPos = this.joystickBg.inverseTransformPoint(
v3(), v3(touchPos.x, touchPos.y, 0)
);
const distance = localPos.length();
if (distance > this.radius) {
localPos.x = this.radius * localPos.x / distance;
localPos.y = this.radius * localPos.y / distance;
}
this.joystickHandle.setPosition(localPos);
this.moveDir = v2(localPos.x, localPos.y).normalize();
}
onTouchEnd() {
this.joystickHandle.setPosition(Vec3.ZERO);
this.moveDir = v2(0, 0);
}
getMoveDir(): Vec2 { return this.moveDir; }
Detalles clave del joystick virtual:
- Conversión de coordenadas:
inverseTransformPointpasa de coordenadas de pantalla a locales del nodo - Límite de radio:
if (distance > radius)evita que el stick salga del círculo - Normalización:
normalize()devuelve vector unitario listo para multiplicar por velocidad
En juegos de vuelo o endless runner, donde no hace falta dirección fina, puedes usar toque en pantalla completa: deslizar en cualquier punto mueve al personaje. La lógica difiere del joystick y hay que adaptarla al tipo de juego.
Caso práctico — control completo de un personaje de lucha en vista lateral
Integra la arquitectura de nodos, la configuración de animación, la máquina de estados y el control de entrada en un esquema completo.
Estructura de código completa:
Player(nodo raíz)
├── PlayerControl.ts(control de movimiento)
├── PlayerStateMachine.ts(máquina de estados)
└── KeyboardInput.ts(entrada por teclado)
Body(nodo hijo)
├── Sprite(imagen del personaje)
├── Componente Animation
└ AttackEffect(nodo de efecto de ataque, opcional)
Ejemplo completo — PlayerControl.ts:
import { _decorator, Component, Node, Vec2, CCFloat } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('PlayerControl')
export class PlayerControl extends Component {
@property(CCFloat)
speed: number = 200;
private moveDir: Vec2 = new Vec2(0, 0);
update(dt: number) {
if (this.moveDir.mag() > 0.5) {
const vx = this.moveDir.x * this.speed;
this.node.x += vx * dt;
}
}
setMoveDir(dir: Vec2) { this.moveDir = dir; }
}
Ejemplo completo — PlayerStateMachine.ts:
import { _decorator, Component, Animation } from 'cc';
const { ccclass, property } = _decorator;
enum State {
Idle = 'idle',
Move = 'move',
Attack = 'attack'
}
@ccclass('PlayerStateMachine')
export class PlayerStateMachine extends Component {
@property(Animation)
animation: Animation = null;
private currentState: State = State.Idle;
private isMoving: boolean = false;
private attackKeyPressed: boolean = false;
onLoad() { this.animation.play('idle'); }
switchState(newState: State) {
if (this.currentState === newState) return;
this.animation.stop();
this.animation.play(newState);
this.currentState = newState;
}
update(dt: number) {
if (this.isMoving && this.currentState !== State.Move) {
this.switchState(State.Move);
} else if (!this.isMoving && this.currentState !== State.Idle) {
this.switchState(State.Idle);
}
if (this.attackKeyPressed && this.currentState !== State.Attack) {
this.switchState(State.Attack);
this.scheduleOnce(() => { this.switchState(State.Idle); }, 0.5);
}
}
setMoving(value: boolean) { this.isMoving = value; }
triggerAttack() {
this.attackKeyPressed = true;
this.scheduleOnce(() => { this.attackKeyPressed = false; }, 0.1);
}
}
Ejemplo completo — KeyboardInput.ts:
import { _decorator, Component, input, Input, EventKeyboard, KeyCode, Vec2 } from 'cc';
import { PlayerControl } from './PlayerControl';
import { PlayerStateMachine } from './PlayerStateMachine';
const { ccclass, property } = _decorator;
@ccclass('KeyboardInput')
export class KeyboardInput extends Component {
@property(PlayerControl)
playerControl: PlayerControl = null;
@property(PlayerStateMachine)
stateMachine: PlayerStateMachine = null;
private moveDir: Vec2 = new Vec2(0, 0);
onLoad() {
input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
}
onDestroy() {
input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
input.off(Input.EventType.KEY_UP, this.onKeyUp, this);
}
onKeyDown(event: EventKeyboard) {
switch (event.keyCode) {
case KeyCode.KEY_A:
case KeyCode.ARROW_LEFT:
this.moveDir.x = -1; break;
case KeyCode.KEY_D:
case KeyCode.ARROW_RIGHT:
this.moveDir.x = 1; break;
case KeyCode.KEY_J:
this.stateMachine.triggerAttack(); break;
}
}
onKeyUp(event: EventKeyboard) {
switch (event.keyCode) {
case KeyCode.KEY_A:
case KeyCode.ARROW_LEFT:
case KeyCode.KEY_D:
case KeyCode.ARROW_RIGHT:
this.moveDir.x = 0; break;
}
}
update(dt: number) {
this.playerControl.setMoveDir(this.moveDir);
this.stateMachine.setMoving(this.moveDir.mag() > 0.5);
}
}
Comportamiento al ejecutar:
- Al iniciar → animación de reposo
- A/D o flechas izquierda/derecha → movimiento + animación de caminar
- Tecla J → ataque + animación de ataque → reposo a los 0,5 s
- Al soltar dirección → el personaje se detiene + animación de reposo
Problemas frecuentes y soluciones:
- Cortes al cambiar animación: comprueba que el framerate del AnimationClip no sea demasiado bajo (recomendado 12-24 fps)
- Estado residual: si tras el ataque no vuelve a idle, revisa que el callback del temporizador se ejecute
- Conflicto de entrada: si el personaje se mueve mientras ataca, verifica que en la máquina de estados el ataque se evalúe antes que el movimiento
Con el código integrado, la lógica queda clara: movimiento en PlayerControl, animación en StateMachine, entrada en KeyboardInput; los tres scripts se comunican por interfaces sin interferirse.
Resumen
La arquitectura central son tres capas:
- Separación de nodos: Player gestiona movimiento, Body gestiona animación, sin solaparse
- Máquina de estados: tres estados idle, move y attack con transiciones claras y centralizadas
- Control de entrada: teclado en PC, joystick virtual en móvil; elige según el tipo de juego
La ventaja de estas tres capas: cambiar una no obliga a tocar las otras. Subes la velocidad de movimiento sin tocar la animación; añades efectos al ataque sin tocar el script de movimiento; cambias a joystick virtual sin reescribir la máquina de estados.
Como siguiente paso, puedes combinar esto con el artículo 4 de esta serie sobre diseño de máquinas de estados en minijuegos y su arquitectura anidada en tres niveles, para gestionar estados más complejos: salto, herido, muerte, habilidades, etc. La máquina del personaje es un submódulo de la del juego; la extensión sigue el mismo patrón: definir estados, fijar condiciones de transición y centralizar los cambios.
Flujo de implementación de movimiento y ataque en Cocos Creator
Desde el diseño de la arquitectura de nodos hasta el control de entrada, implementación completa de un personaje de lucha en vista lateral
⏱️ Estimated time: 45 min
- 1
Step 1: Diseñar la arquitectura de nodos
Crea el nodo raíz Player con el script de control de movimiento y el nodo hijo Body con el componente de animación y el Sprite. Player solo modifica la coordenada x; Body gestiona el salto en y y el cambio de fotogramas de animación. - 2
Step 2: Configurar el componente de animación
Añade el componente Animation al nodo Body y arrastra los recursos AnimationClip de reposo, caminar, atacar, etc. Establece defaultClip como la animación de reposo y activa playOnLoad. En el código, cambia de animación con animation.play('clipName'). - 3
Step 3: Implementar la máquina de estados
Define el enum State (Idle, Move, Attack) e implementa el método switchState. En update, evalúa isMoving y attackKeyPressed para cambiar de estado; usa scheduleOnce para volver a idle tras 0,5 segundos en el estado de ataque. - 4
Step 4: Conectar el control de entrada
Crea el script KeyboardInput para escuchar eventos KEY_DOWN y KEY_UP. A/D y las flechas izquierda/derecha controlan la dirección; la tecla J dispara el ataque. En update, sincroniza moveDir cada fotograma con PlayerControl y StateMachine. - 5
Step 5: Probar y ajustar
Ejecuta el juego y verifica: la animación de reposo se reproduce correctamente; al pulsar las flechas el personaje se mueve y cambia a caminar; la tecla de ataque dispara la animación de ataque y vuelve a reposo automáticamente; las transiciones son fluidas sin cortes.
FAQ
¿Por qué separar Player y Body en dos nodos?
Al cambiar de animación el personaje ataca mientras se desliza, ¿cómo solucionarlo?
Tras el ataque el personaje sigue en fotogramas de ataque y no vuelve a reposo?
¿Qué esquema de entrada elegir en PC y en móvil?
¿Cómo implementar límite de radio y normalización en el joystick virtual?
¿Por qué el estado de ataque vuelve a idle con temporizador y no al soltar la tecla?
12 min de lectura · Publicado el: 21 may 2026 · Actualizado el: 21 ago 2026
Desarrollo de mini juegos Cocos asistido por IA
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Convenciones de nombres para recursos UI de minijuegos: transparentes, botones, iconos y sprites
Domina las convenciones de nombres para recursos UI de minijuegos: fórmula completa para estados de botones, categorías de iconos y frames de animación de personajes, con 7 reglas de oro del equipo de Tencent Games para que tus archivos sean claros de un vistazo y la colaboración del equipo sea un 80% más eficiente.
Parte 9 de 21
Siguiente
De dónde viene el game feel en minijuegos: flash blanco, vibración, texto flotante, sonido y partículas
Guía práctica de game feel: del marco de 19 características a parámetros concretos (flash blanco 50-100 ms, vibración 0,08 s a 40 Hz, sonido flotante a 12 ms) con ejemplos de código para Cocos Creator y Unity.
Parte 11 de 21



Comentarios
Inicia sesión con GitHub para dejar un comentario