Alternar tema

Movimento e ataque de personagem no Cocos: de nós a animações

Easton editorial illustration: one game character moving through idle, move, attack, and return poses above a compact node rig

Seu personagem está no centro da tela. Você pressiona a tecla direcional e nada acontece. Ao abrir o código, encontra a lógica de movimento, a reprodução de animações e as verificações de estado todas empilhadas dentro de um único update. Você muda a animação e esquece de mudar o estado; muda o estado e esquece a velocidade. No fim, nem você sabe mais o que o personagem deveria estar fazendo naquele momento.

Separar nós parece um pouco abstrato, mas a ideia é simples: movimento e animação precisam ser controlados separadamente. O nó Player fica responsável pelo deslocamento horizontal; o nó Body cuida do salto vertical e da animação de ataque. Os dois rodam de forma independente, sem se atrapalhar. Este artigo começa pela arquitetura de nós, passa pela configuração do componente de animação, pela implementação da máquina de estados e pela escolha do esquema de entrada, e termina com um conjunto completo de scripts para um personagem de luta lateral.

Arquitetura de nós do personagem — movimento horizontal + animação vertical sobreposta

O tutorial oficial do Cocos Creator 3.7 tem um detalhe de projeto fácil de ignorar: separar Player e Body em dois nós. Muita gente, eu incluído no começo, acha desnecessário. Afinal, não parece mais simples usar um único nó para movimento, animação e detecção de colisão?

O problema aparece quando animação e movimento têm ritmos diferentes. Imagine o personagem saltando: no eixo horizontal ele avança em velocidade constante; no eixo vertical ele desenha uma parábola. Com um único nó, o update precisa calcular o deslocamento horizontal e controlar a altura do salto ao mesmo tempo. O código começa organizado e logo perde o rumo. Quando entram animação de ataque, efeito de dano e até rotação ou escala na morte, a lógica de um nó só praticamente sai do controle.

A vantagem da separação é direta: movimento fica com movimento, animação fica com animação. O nó Player cuida apenas do aumento ou redução da coordenada x; o nó Body cuida apenas da coordenada y nos saltos e da troca de quadros de animação. Um não disputa responsabilidade com o outro.

Estrutura dos nós:

Player (nó raiz)
  ├── PlayerControl.ts (lógica de movimento)
  └── Body (nó filho)
       ├── Sprite (imagem do personagem)
       └── componente Animation

Exemplo de código — controle de movimento no nó Player:

// PlayerControl.ts - script do nó Player
@property(CCFloat)
speed: number = 200; // Velocidade de movimento

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; // Altera apenas a posição horizontal
  }
}

// Chamado externamente para definir a direção
setMoveDir(dir: Vec2) {
  this.moveDir = dir;
}

Exemplo de código — controle de animação no nó Body:

// BodyControl.ts - script do nó Body
@property(Animation)
animation: Animation = null;

jump(height: number = 100) {
  // Animação de salto: deslocamento vertical + quadros da animação jump
  this.node.runAction(cc.jumpBy(1.0, 0, 0, height, 1));
  this.animation.play('jump');
}

attack() {
  this.animation.play('attack');
  // Depois do ataque, volta automaticamente para idle
  this.scheduleOnce(() => {
    this.animation.play('idle');
  }, 0.5);
}

Com isso, a lógica de movimento fica no nó Player e a lógica de animação fica no nó Body. Alterar a altura do salto não exige tocar no código de movimento; mudar a velocidade também não exige procurar dentro do script de animação. A manutenção fica bem menos cansativa.

Configuração do componente de animação — vínculo entre Animation e AnimationClip

O componente de animação é um módulo nativo do Cocos Creator, mas alguns atributos são especialmente importantes: defaultClip, o array clips e o playOnLoad, que muita gente passa batido.

Principais atributos do componente Animation:

  • defaultClip: a animação tocada por padrão, normalmente a animação de idle
  • clips: array de recursos de animação, como idle, caminhada e ataque
  • currentClip: animação em reprodução no momento, usada em tempo de execução

O passo a passo é simples:

  1. Adicione um componente Animation ao nó Body
  2. Arraste os recursos AnimationClip para o array clips
  3. Defina defaultClip como a animação de idle
  4. Marque playOnLoad para o personagem já começar parado

Exemplo de código — configuração do componente de animação:

// PlayerAnimation.ts - script de controle de animação
@property(Animation)
animation: Animation = null;

@property([AnimationClip])
clips: AnimationClip[] = []; // Idle, caminhada, ataque, salto

onLoad() {
  this.animation.clips = this.clips;
  this.animation.defaultClip = this.clips[0]; // Idle padrão
  this.animation.play();
}

playIdle() { this.animation.play('idle'); }
playMove() { this.animation.play('move'); }
playAttack() {
  this.animation.play('attack');
  this.scheduleOnce(() => { this.playIdle(); }, 0.5);
}

Um detalhe importante: ao trocar de animação, é melhor chamar stop() antes de play(), principalmente em clipes de execução única, como ataques. Sem isso, se o personagem estiver andando e você pressionar o ataque, a animação de ataque pode disputar quadros com a animação de caminhada. Na tela, parece que o personagem está golpeando enquanto desliza, o que fica bem estranho.

Forma correta de trocar animações:

switchAnimation(name: string) {
  if (this.animation.currentClip?.name !== name) {
    this.animation.stop();
    this.animation.play(name);
  }
}

Com o componente de animação configurado, o próximo passo é a máquina de estados. Ela faz a troca de animações seguir uma regra clara, em vez de espalhar verificações como if (velocidade > 0) play('move') por vários scripts.

Máquina de estados de animação — idle → movimento → ataque → volta para idle

A ideia central de uma máquina de estados é esta: em qualquer instante, o personagem só pode estar em um estado, como idle, move ou attack, e as transições entre estados precisam ter condições explícitas. Não é troca aleatória; existe uma regra.

No começo, também achei que máquina de estados fosse projeto demais para algo simples. Afinal, é só tocar uma animação; chamar play('attack') não resolve? Resolve até você adicionar salto, depois dano, e então descobrir que as verificações de transição ficaram espalhadas por todo lado: no script de movimento, no script de animação e no tratamento de entrada. Você muda uma condição em um lugar, esquece de sincronizar nos outros dois, e o comportamento do personagem começa a quebrar.

Fluxo da máquina de estados:

[Entry] → [idle]
          ↓ speed>0
         [move]
          ↓ attackKey
         [attack] → [Exit] → [idle]

São três estados: idle, move e attack. As condições de transição são:

  • idle → move: velocidade maior que 0
  • move → idle: velocidade igual a 0
  • any → attack: tecla de ataque pressionada
  • attack → idle: fim da animação de ataque

Exemplo de código — implementação da 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 repetição
  this.animation.stop();
  this.animation.play(newState);
  this.currentState = newState;
}

update(dt: number) {
  // Verificação do estado de movimento
  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, com prioridade maior que movimento
  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);
}

Os pontos mais importantes da máquina de estados:

  • Prioridade de estado: ataque tem prioridade sobre movimento, então o personagem não se move durante o ataque
  • Proteção contra troca repetida: if (currentState === newState) return evita tocar o mesmo estado várias vezes
  • Callback de timer: depois que a animação de ataque termina, o personagem volta automaticamente para idle, sem depender da tecla ser solta

A máquina de estados concentra a lógica de troca de animações em um único lugar. Se depois você quiser adicionar salto, dano ou morte, basta incluir novos valores no enum State e acrescentar as condições de transição. É muito mais fácil de expandir do que um conjunto solto de if.

Comparação de controles de entrada — teclado vs toque vs joystick virtual

Os três esquemas funcionam, mas cada um se encaixa em um cenário. Escolher errado afeta diretamente a sensação de controle.

EsquemaCenário idealVantagensDesvantagensComplexidade
TecladoPC e fase de depuraçãoSimples, direto e responsivoNão funciona no celularBaixa
ToqueJogos com movimento em tela cheia, como voo e corrida infinitaNatural e sem UI cobrindo a telaMenos precisão de direção e mais chance de toque acidentalMédia
Joystick virtualARPG e jogos de lutaDireção precisa e controle em 360 grausOcupa espaço na telaAlta

Entrada por teclado, a melhor opção no PC

Se o minigame roda principalmente no PC, ou se você está em fase de depuração e quer validar a lógica rápido, o teclado é a solução mais simples.

Exemplo 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);
}

Detalhes da entrada por teclado:

  • Mapeamento de teclas: A/D e setas esquerda/direita são aceitas ao mesmo tempo, cobrindo hábitos diferentes de jogadores
  • Tecla de ataque: J, comum em jogos de luta
  • Sincronização de estado: a direção de movimento e a máquina de estados são atualizadas a cada frame

Joystick virtual, a melhor opção no celular

Se o minigame roda no celular e é um jogo de luta lateral ou ARPG, onde direção precisa importa, o joystick virtual quase sempre é a escolha certa.

Pontos essenciais na implementação de um joystick virtual:

  • Limite de área: o centro do joystick não pode ultrapassar o círculo de fundo
  • Normalização da direção: o retorno deve ser um vetor unitário
  • Soltar o toque: o joystick volta automaticamente para o centro

Exemplo de código — implementação de 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; }

Detalhes importantes do joystick virtual:

  • Conversão de coordenadas: inverseTransformPoint transforma coordenadas de tela em coordenadas locais do nó
  • Limite de raio: if (distance > radius) garante que o joystick não saia do círculo de fundo
  • Normalização: normalize() retorna um vetor unitário, pronto para o script de movimento multiplicar pela velocidade

Se você está fazendo um jogo de voo ou corrida infinita, em que a direção não precisa ser tão precisa, o toque em tela cheia pode funcionar melhor: o jogador desliza o dedo em qualquer ponto da tela para controlar o personagem. Mas essa lógica de entrada é diferente da lógica de um joystick virtual e precisa ser ajustada ao tipo de jogo.

Caso prático — implementação completa de controle para personagem de luta lateral

Agora vamos juntar a arquitetura de nós, a configuração de animação, a máquina de estados e o controle de entrada em uma implementação completa para um personagem de luta lateral.

Estrutura completa do código:

Player (nó raiz)
  ├── PlayerControl.ts (controle de movimento)
  ├── PlayerStateMachine.ts (máquina de estados)
  └── KeyboardInput.ts (entrada por teclado)

Body (nó filho)
  ├── Sprite (imagem do personagem)
  ├── componente Animation
  └ AttackEffect (nó opcional para efeito de ataque)

Exemplo 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; }
}

Exemplo 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);
  }
}

Exemplo 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);
  }
}

Resultado ao rodar:

  • Iniciar o jogo → a animação de idle do personagem toca
  • Pressionar A/D ou as setas esquerda/direita → o personagem se move e troca para a animação de caminhada
  • Pressionar J → o personagem ataca, toca a animação de ataque e volta para idle depois de 0,5 segundo
  • Soltar a tecla direcional → o personagem para de se mover e volta para a animação de idle

Problemas comuns e soluções:

  1. Travamento na troca de animação: confira se a taxa de quadros do AnimationClip está baixa demais. Uma faixa de 12 a 24 fps costuma funcionar bem.
  2. Estado residual: se o personagem não volta para idle depois do ataque, verifique se o callback do timer está sendo executado corretamente.
  3. Conflito de entrada: se o personagem ainda se move durante o ataque, confira se a prioridade do estado de ataque foi colocada antes da lógica de movimento na máquina de estados.

Depois de integrar tudo, a lógica de controle fica clara: movimento no PlayerControl, animação na StateMachine e entrada no KeyboardInput. Os três scripts conversam por interfaces simples e não se atropelam.

Resumo

A arquitetura principal tem três camadas:

  1. Separação de nós: Player cuida do movimento, Body cuida da animação, sem disputa de responsabilidade
  2. Máquina de estados: três estados, idle, move e attack, com condições de transição concentradas
  3. Controle de entrada: teclado no PC, joystick virtual no celular, escolhido de acordo com o tipo de jogo

A vantagem dessa arquitetura é que uma mudança não derruba a outra. Aumentar a velocidade de movimento não exige mexer na animação; adicionar efeito ao ataque não exige tocar no script de movimento; trocar a entrada por joystick virtual não muda a lógica da máquina de estados.

Como próximo passo, você pode combinar esta implementação com a arquitetura em três camadas do quarto artigo da série, sobre design de máquina de estados para minigames, e adicionar um gerenciamento de estados mais complexo ao personagem: salto, dano, morte, uso de habilidades e assim por diante. A máquina de estados do personagem é apenas um submódulo da máquina de estados do jogo. A forma de expandir é a mesma: definir estados, configurar condições de transição e centralizar a lógica de troca.

Fluxo para implementar movimento e ataque de personagem no Cocos Creator

Da arquitetura de nós ao controle de entrada, implemente um controle completo para personagem de luta lateral

⏱️ Estimated time: 45 min

  1. 1

    Step 1: Projetar a arquitetura de nós

    Crie um nó raiz Player para receber o script de controle de movimento e um nó filho Body para receber o componente Animation e o Sprite. O Player controla apenas o aumento e a redução da coordenada x; o Body cuida dos saltos no eixo y e da troca de quadros de animação.
  2. 2

    Step 2: Configurar o componente de animação

    Adicione um componente Animation ao nó Body e arraste os recursos AnimationClip de idle, caminhada, ataque e outros para ele. Defina defaultClip como a animação de idle e marque playOnLoad. No código, use animation.play('clipName') para alternar animações.
  3. 3

    Step 3: Implementar a máquina de estados

    Defina o enum State, com Idle, Move e Attack, e implemente o método switchState. No update, use isMoving e attackKeyPressed para decidir as transições. No estado de ataque, use um timer scheduleOnce para voltar a idle depois de 0,5 segundo.
  4. 4

    Step 4: Conectar o controle de entrada

    Crie o script KeyboardInput para escutar os eventos KEY_DOWN e KEY_UP. A/D e as setas esquerda/direita controlam a direção de movimento; a tecla J dispara o ataque. No update, sincronize moveDir a cada frame com PlayerControl e StateMachine.
  5. 5

    Step 5: Testar e ajustar

    Rode o jogo e valide: a animação de idle toca corretamente, as teclas direcionais movem o personagem e alternam para a animação de caminhada, a tecla de ataque dispara a animação de ataque e volta automaticamente para idle, sem travamentos na troca.

FAQ

Por que separar Player e Body em dois nós?
Porque movimento e animação não seguem o mesmo ritmo. Durante um salto, o eixo horizontal avança em velocidade constante, enquanto o eixo vertical desenha uma parábola. Se tudo ficar em um único update, calculando deslocamento e altura da animação ao mesmo tempo, o código vira uma bagunça rápido. Com a separação, Player cuida só da coordenada x; Body cuida de y e dos quadros de animação. A lógica fica mais clara e fácil de manter.
Como resolver quando o personagem ataca e desliza ao mesmo tempo?
Isso costuma acontecer quando a troca de animação chama play sem parar a animação anterior. O caminho correto é verificar se a animação atual já é a animação de destino; se for diferente, chame stop() antes de play(). Além disso, o estado de ataque precisa ter prioridade maior que o estado de movimento, e a entrada de movimento deve ser desativada durante o ataque.
Depois da animação de ataque, o personagem continua no quadro de ataque e não volta para idle. O que verificar?
Confira se o callback do timer está sendo executado. Em triggerAttack, defina attackKeyPressed como true e depois use scheduleOnce(() => { this.attackKeyPressed = false; }, 0.1) para impedir disparos repetidos do sinal de ataque. Depois que a animação de ataque começar, use scheduleOnce(() => { this.switchState(State.Idle); }, 0.5) para voltar automaticamente para idle.
No PC e no celular, qual esquema de entrada devo escolher?
No PC, use teclado: é simples, direto e ótimo para depuração. No celular, escolha pelo tipo de jogo: luta lateral e ARPG precisam de direção precisa, então combinam com joystick virtual; jogos de voo e corrida infinita podem usar toque em tela cheia. Ao implementar um joystick virtual, preste atenção ao limite da área e à normalização da direção.
Como implementar limite e normalização em um joystick virtual?
Escute o evento TOUCH_MOVE para obter a posição do toque e use inverseTransformPoint para convertê-la para as coordenadas locais do nó. Calcule a distância entre o ponto tocado e o centro. Se distance > radius, reduza proporcionalmente até radius. Por fim, use normalize() para obter um vetor unitário, pronto para o script de movimento multiplicar pela velocidade.
Por que o estado de ataque usa um timer para voltar a idle em vez de esperar a tecla ser solta?
Ataque é uma ação de disparo único: aperta uma vez, toca a animação inteira. Ele não deveria depender de quanto tempo a tecla ficou pressionada. Um toque rápido e um toque longo devem produzir o mesmo resultado: tocar a animação de ataque e voltar para idle depois de 0,5 segundo. O timer garante que a animação seja reproduzida por completo e evita diferenças de comportamento causadas pelo jeito como o jogador pressiona a tecla.

13 min de leitura · Publicado em: 21 mai 2026 · Atualizado em: 14 jul 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog