Changer le thème

Déplacement et attaque du personnage Cocos : des nœuds à l'animation

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

Votre personnage est au centre de l’écran ; vous appuyez sur une flèche directionnelle — il ne bouge pas. Vous ouvrez le code : déplacement, animations et états sont entassés dans un seul update. Vous modifiez l’animation et oubliez l’état, ou l’inverse, et vous ne savez plus ce que le personnage devrait faire.

La séparation des nœuds peut sembler abstraite : en pratique, déplacement et animation sont gérés à part — le nœud Player pour le déplacement horizontal, Body pour le saut vertical et les animations d’attaque, chacun indépendamment. Cet article part de l’architecture des nœuds, configure le composant Animation, implémente la machine à états, compare les schémas d’entrée, et fournit un contrôle complet pour un personnage de combat en vue latérale.

Architecture des nœuds — déplacement horizontal + animation verticale

Le tutoriel officiel Cocos Creator 3.7 propose une conception souvent négligée : séparer Player et Body. Beaucoup (moi y compris au début) trouvent cela inutile — un seul nœud pour déplacement, animation et collisions, non ?

Le problème vient du rythme différent. Imaginez un saut : horizontal uniforme, vertical en parabole. Avec un seul nœud, update calcule à la fois le déplacement horizontal et la hauteur — le code devient vite ingérable. Ajoutez attaque, effet de dégâts, rotation à la mort : la logique d’un seul nœud dérape.

L’intérêt de la séparation : déplacement d’un côté, animation de l’autre. Player ne modifie que x ; Body gère y, saut et frames d’animation — sans se marcher dessus.

Structure des nœuds :

Player(nœud racine)
  ├── PlayerControl.ts(logique de déplacement)
  └── Body(nœud enfant)
       ├── Sprite(image du personnage)
       └── composant Animation

Exemple — contrôle du déplacement sur Player :

// PlayerControl.ts - script du nœud Player
@property(CCFloat)
speed: number = 200; // vitesse de déplacement

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; // position horizontale uniquement
  }
}

// appel externe pour définir la direction
setMoveDir(dir: Vec2) {
  this.moveDir = dir;
}

Exemple — contrôle d’animation sur Body :

// BodyControl.ts - script du nœud Body
@property(Animation)
animation: Animation = null;

jump(height: number = 100) {
  // animation de saut : déplacement vertical + frames jump
  this.node.runAction(cc.jumpBy(1.0, 0, 0, height, 1));
  this.animation.play('jump');
}

attack() {
  this.animation.play('attack');
  // retour automatique à idle après l'attaque
  this.scheduleOnce(() => {
    this.animation.play('idle');
  }, 0.5);
}

Déplacement sur Player, animation sur Body : modifier la hauteur de saut sans toucher au déplacement, ou la vitesse sans fouiller les scripts d’animation — maintenance nettement plus simple.

Configuration du composant Animation — Animation + AnimationClip

Le composant Animation est intégré à Cocos Creator ; quelques propriétés comptent : defaultClip, le tableau clips, et playOnLoad souvent oublié.

Propriétés clés du composant Animation :

  • defaultClip : animation par défaut (souvent idle)
  • clips : tableau de ressources (idle, marche, attaque, etc.)
  • currentClip : clip en cours (état runtime)

Étapes de configuration :

  1. Ajoutez Animation sur le nœud Body
  2. Glissez les AnimationClip dans le tableau clips
  3. Définissez defaultClip sur l’animation idle
  4. Cochez playOnLoad (le personnage démarre en idle)

Exemple — configuration du composant Animation :

// PlayerAnimation.ts - script de contrôle d'animation
@property(Animation)
animation: Animation = null;

@property([AnimationClip])
clips: AnimationClip[] = []; // idle, marche, attaque, saut

onLoad() {
  this.animation.clips = this.clips;
  this.animation.defaultClip = this.clips[0]; // idle par défaut
  this.animation.play();
}

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

Détail utile : avant un nouveau play(), appelez souvent stop(), surtout pour les clips « one-shot » comme l’attaque. Sinon, en marchant puis en appuyant sur attaque, les animations se chevauchent — le personnage semble frapper en glissant.

Bonne pratique de commutation :

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

Une fois le composant configuré, place pour la machine à états — des transitions régulières plutôt que des if (vitesse>0) play('move') éparpillés.

Machine à états — idle → move → attack → retour idle

Principe : le personnage n’est qu’à un état à la fois (idle, move, attack), avec des conditions de transition explicites — pas de changements au hasard.

Au début, la machine à états semblait excessive — play('attack') suffit, non ? Puis viennent saut, blessure, mort : les tests sont partout (déplacement, animation, entrées). On modifie un endroit, on oublie les autres — bugs de comportement.

Diagramme de flux :

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

Trois états : idle, move, attack. Transitions :

  • idle → move : vitesse > 0
  • move → idle : vitesse = 0
  • any → attack : touche d’attaque
  • attack → idle : fin de l’animation d’attaque

Exemple — implémentation de la machine à états :

// 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; // éviter les doublons
  this.animation.stop();
  this.animation.play(newState);
  this.currentState = newState;
}

update(dt: number) {
  // état de déplacement
  if (this.isMoving && this.currentState !== State.Move) {
    this.switchState(State.Move);
  } else if (!this.isMoving && this.currentState !== State.Idle) {
    this.switchState(State.Idle);
  }

  // état d'attaque (priorité sur le déplacement)
  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);
}

Points clés :

  • Priorité : attaque avant déplacement (pas de déplacement pendant l’attaque)
  • Anti-doublon : if (currentState === newState) return
  • Timer : retour à idle après l’attaque, pas à la relâche de touche

La logique de commutation est centralisée ; ajouter saut, blessure ou mort = nouvelles valeurs dans State et nouvelles transitions — bien plus extensible que des if dispersés.

Comparaison des entrées — clavier vs tactile vs joystick virtuel

Chaque schéma a son usage ; un mauvais choix dégrade la prise en main.

SchémaUsageAvantagesInconvénientsComplexité
ClavierPC, debugSimple, réactifIndisponible sur mobileFaible
TactileVol, runner plein écranNaturel, sans UIDirection imprécise, faux touchersMoyenne
Joystick virtuelARPG, combatDirection précise, 360°Occupe l’écranÉlevée

Clavier (PC en priorité)

Pour un mini-jeu surtout sur PC, ou pour valider la logique en debug, le clavier est le plus simple.

Exemple — entrée clavier :

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

Détails clavier :

  • Mapping : A/D et flèches gauche/droite
  • Attaque : touche J (courante en combat)
  • Sync : direction et machine à états mis à jour chaque frame

Joystick virtuel (mobile en priorité)

Sur mobile, pour combat latéral ou ARPG avec direction précise, le joystick virtuel est quasi indispensable.

Points d’implémentation :

  • Limite de zone : le curseur ne dépasse pas le cercle de fond
  • Normalisation : vecteur unitaire en sortie
  • Relâchement : retour au centre

Exemple — joystick virtuel :

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

Détails joystick :

  • Conversion : inverseTransformPoint pour les coordonnées locales
  • Rayon : if (distance > radius) garde le curseur dans le cercle
  • Normalisation : normalize() pour multiplier par la vitesse

Pour vol ou runner sans direction fine, le tactile plein écran convient — logique différente du joystick, à adapter au jeu.

Cas pratique — contrôle complet d’un personnage de combat latéral

Intégration de l’architecture, de l’animation, de la machine à états et des entrées.

Structure du code :

Player(nœud racine)
  ├── PlayerControl.ts(déplacement)
  ├── PlayerStateMachine.ts(machine à états)
  └── KeyboardInput.ts(clavier)

Body(nœud enfant)
  ├── Sprite(image)
  ├── composant Animation
  └ AttackEffect(effet d'attaque, optionnel)

Exemple complet — 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; }
}

Exemple complet — 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);
  }
}

Exemple complet — 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);
  }
}

Comportement attendu :

  • Démarrage → animation idle
  • A/D ou flèches → déplacement + marche
  • J → attaque → retour idle après 0,5 s
  • Relâchement des directions → arrêt + idle

Problèmes courants :

  1. Saccades : vérifier le framerate des AnimationClip (12–24 fps recommandé)
  2. État résiduel : pas de retour à idle après attaque → vérifier le callback du timer
  3. Conflit d’entrée : déplacement pendant l’attaque → prioriser attack avant move dans update

Logique claire : déplacement dans PlayerControl, animation dans StateMachine, entrées dans KeyboardInput — communication par interfaces.

Résumé

Trois couches :

  1. Nœuds séparés : Player pour le déplacement, Body pour l’animation
  2. Machine à états : idle, move, attack avec transitions centralisées
  3. Entrées : clavier sur PC, joystick virtuel sur mobile selon le jeu

Avantage : modifier une couche n’impacte pas les autres. Vitesse sans toucher aux animations ; effets d’attaque sans toucher au déplacement ; joystick sans refondre la machine à états.

Ensuite, combinez avec l’article 4 de la série sur la machine à états à trois niveaux pour ajouter saut, blessure, mort, compétences — le personnage n’est qu’un sous-module, même logique : états, transitions, gestion centralisée.

Flux d'implémentation du déplacement et de l'attaque dans Cocos Creator

De la conception de l'architecture des nœuds au contrôle des entrées, implémentation complète d'un personnage de combat en vue latérale

⏱️ Estimated time: 45 min

  1. 1

    Step 1: Concevoir l'architecture des nœuds

    Créez le nœud racine Player avec le script de contrôle du déplacement, et le nœud enfant Body avec le composant Animation et le Sprite. Player ne modifie que la coordonnée x ; Body gère le saut sur l'axe y et le changement de frames d'animation.
  2. 2

    Step 2: Configurer le composant Animation

    Ajoutez le composant Animation sur Body, assignez les AnimationClip (idle, marche, attaque, etc.). Définissez defaultClip sur l'animation idle, cochez playOnLoad. Dans le code, basculez avec animation.play('clipName').
  3. 3

    Step 3: Implémenter la machine à états

    Définissez l'énumération State (Idle, Move, Attack) et la méthode switchState. Dans update, basculez selon isMoving et attackKeyPressed ; utilisez scheduleOnce pour revenir à idle 0,5 s après l'attaque.
  4. 4

    Step 4: Brancher les entrées

    Créez le script KeyboardInput pour écouter KEY_DOWN et KEY_UP. A/D et flèches gauche/droite pour la direction, J pour l'attaque. Dans update, synchronisez moveDir vers PlayerControl et StateMachine à chaque frame.
  5. 5

    Step 5: Tester et ajuster

    Lancez le jeu : animation idle OK, touches directionnelles déplacent le personnage et activent la marche, J déclenche l'attaque puis retour à idle après 0,5 s, transitions fluides sans saccades.

FAQ

Pourquoi séparer Player et Body en deux nœuds ?
Le rythme du déplacement et de l'animation n'est pas le même. En saut, le mouvement horizontal est uniforme et le vertical suit une parabole ; un seul nœud mélange déplacement et hauteur d'animation dans update. Après séparation, Player ne gère que x, Body gère y et les frames — logique claire et maintenable.
Le personnage glisse en frappant : comment corriger ?
Souvent dû à un play sans stop préalable. Avant de changer d'animation, vérifiez si la clip courante diffère de la cible ; sinon stop() puis play(). L'état attack doit primer sur move : désactivez le déplacement pendant l'attaque.
Après l'attaque, la frame d'attaque reste affichée sans retour à idle ?
Vérifiez que le callback de minuterie s'exécute. Dans triggerAttack, mettez attackKeyPressed à true, puis scheduleOnce(() => { this.attackKeyPressed = false; }, 0.1) pour éviter les déclenchements multiples. Après l'animation d'attaque, scheduleOnce(() => { this.switchState(State.Idle); }, 0.5) ramène à idle.
Quel schéma d'entrée pour PC et mobile ?
PC : clavier (simple, idéal au debug). Mobile selon le jeu : combat latéral ou ARPG avec direction précise → joystick virtuel ; vol ou runner → tactile plein écran. Pour le joystick, limitez la zone et normalisez la direction.
Comment limiter la zone du joystick et normaliser la direction ?
Écoutez TOUCH_MOVE, convertissez la position avec inverseTransformPoint en coordonnées locales. Calculez distance au centre ; si distance > radius, ramenez proportionnellement à radius. Terminez avec normalize() pour un vecteur unitaire multiplié par la vitesse.
Pourquoi un timer pour revenir à idle après l'attaque, plutôt que la relâche de touche ?
L'attaque est une action « une fois déclenchée, animation complète », indépendante de la durée d'appui. Appui court ou long : même séquence attaque → idle après 0,5 s. Le timer garantit une lecture complète sans variation selon le joueur.

9 min de lecture · Publié le: 21 mai 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog