Switch Language
中文 Translating English 日本語
Toggle Theme

Cocos Mini-Game Character Movement & Attack: Implementation from Nodes to Animation

Your character stands in the center of the screen. You press the arrow keys—nothing moves. You open the code and find movement logic, animation playback, and state judgment all piled into one update function. You changed the animation but forgot to update the state, you changed the state but forgot to update the speed, and finally you don’t even know what the character should be doing.

Node separation sounds abstract, but simply put, it’s separating movement and animation management—Player node handles horizontal displacement, Body node manages vertical jumping and attack animation, both running independently without interfering with each other. This article takes you from node architecture, through animation component configuration, state machine implementation, and input control solution selection, finally providing a complete set of side-scrolling fighting game character code.

Character Node Architecture Design — Horizontal Movement + Vertical Animation Overlay

Cocos Creator 3.7’s official tutorial has an easily overlooked design: Player and Body nodes are separated. Many people (including myself initially) think this is unnecessary—wouldn’t it be simple to use one node to manage movement, animation, and collision detection?

The problem lies in the inconsistent rhythm of animation and movement. Imagine your character is jumping—horizontally flying forward at constant speed, vertically drawing a parabola. If using a single node, update has to calculate horizontal displacement while also controlling jump height, and the code quickly becomes messy. By the time you add attack animation, hit effects, and even death rotation/scaling, a single node’s logic is basically out of control.

Benefits of node separation: movement is movement, animation is animation. Player node only manages x-coordinate increments/decrements, Body node only manages y-coordinate jumping and animation frame switching, neither stealing the show from the other.

Node Structure Diagram:

Player (Root Node)
  ├── PlayerControl.ts (Movement Logic)
  └── Body (Child Node)
       ├── Sprite (Character Image)
       └── Animation Component

Code Example — Player Node Movement Control:

// PlayerControl.ts - Player node script
@property(CCFloat)
speed: number = 200; // Movement speed

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; // Only modify horizontal position
  }
}

// External call to set direction
setMoveDir(dir: Vec2) {
  this.moveDir = dir;
}

Code Example — Body Node Animation Control:

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

jump(height: number = 100) {
  // Jump animation: vertical displacement + jump animation frame
  this.node.runAction(cc.jumpBy(1.0, 0, 0, height, 1));
  this.animation.play('jump');
}

attack() {
  this.animation.play('attack');
  // Automatically return to idle after attack ends
  this.scheduleOnce(() => {
    this.animation.play('idle');
  }, 0.5);
}

This way, movement logic is in the Player node, animation logic is in the Body node. Changing jump height doesn’t require touching movement code, changing movement speed doesn’t require searching through animation scripts—code maintenance becomes much easier.

Animation Component Configuration — Animation + AnimationClip Binding

The animation component is a built-in module of Cocos Creator, but you might have overlooked some key properties: defaultClip, clips array, and the easily ignored playOnLoad.

Core Properties of Animation Component:

  • defaultClip: The default animation to play (usually idle animation)
  • clips: Animation resource array (idle, walk, attack, etc. AnimationClips)
  • currentClip: Currently playing animation (runtime state)

Configuration steps are simple:

  1. Add Animation component to Body node
  2. Drag AnimationClip resources into clips array
  3. Set defaultClip to idle animation
  4. Check playOnLoad (make character idle at start)

Code Example — Animation Component Configuration:

// PlayerAnimation.ts - Animation control script
@property(Animation)
animation: Animation = null;

@property([AnimationClip])
clips: AnimationClip[] = []; // idle, walk, attack, jump

onLoad() {
  this.animation.clips = this.clips;
  this.animation.defaultClip = this.clips[0]; // Default idle
  this.animation.play();
}

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

There’s a detail: when switching animations, it’s best to stop() before play(), especially for “one-time play” clips like attack animations. Without this, when a character is walking and you press the attack key, the attack animation might fight with the walk animation for frames—the screen looks like the character is swinging a sword while sliding, quite weird.

Correct Way to Switch Animations:

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

After configuring the animation component, the next step is the state machine—making animation switching follow rules, rather than scattering if (speed>0) play('move') type judgments everywhere.

Animation State Machine Driven — Idle → Move → Attack → Return to Idle

Core concept of state machine: a character can only be in one state at any time (idle, move, attack), with clear transition conditions between states. Not arbitrary switching, but following rules.

I initially thought state machines were over-engineering—isn’t playing animation just play('attack')? Later I added jump state, then hit state, and finally found state switching judgments scattered everywhere: in movement script, in animation script, in input handling. Changed one judgment logic, forgot to update the other two places, and character behavior starts bugging out.

State Machine Flow Diagram:

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

Three states: idle, move, attack. Transition conditions:

  • idle → move: speed greater than 0
  • move → idle: speed is 0
  • any → attack: attack key pressed
  • attack → idle: attack animation ends

Code Example — State Machine Implementation:

// 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; // Prevent duplicates
  this.animation.stop();
  this.animation.play(newState);
  this.currentState = newState;
}

update(dt: number) {
  // Movement state judgment
  if (this.isMoving && this.currentState !== State.Move) {
    this.switchState(State.Move);
  } else if (!this.isMoving && this.currentState !== State.Idle) {
    this.switchState(State.Idle);
  }

  // Attack state (priority over movement)
  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);
}

Key details of state machine:

  • State Priority: Attack takes priority over movement (cannot move while attacking)
  • Prevent Duplicate Switching: if (currentState === newState) return avoids repeatedly playing same state
  • Timer Callback: Automatically return to idle after attack animation ends, rather than waiting for key release

The state machine centralizes animation switching logic in one place, changing transition conditions only requires modifying one place. Later when adding jump, hit, death states, just add values to State enum and supplement transition conditions—extensibility is much stronger than scattered if judgments.

Input Control Solution Comparison — Keyboard vs Touch vs Virtual Joystick

Each of the three solutions has applicable scenarios, choosing wrong will greatly impact operation feel.

SolutionApplicable ScenariosAdvantagesDisadvantagesImplementation Complexity
KeyboardPC, debugging phaseSimple and direct, fast responseUnavailable on mobileLow
TouchFull-screen movement games (flying, parkour)Natural operation, no UI blockingLow direction precision, easy mis-touchMedium
Virtual JoystickARPG, fighting gamesPrecise direction, 360-degree controlUI occupies screen spaceHigh

Keyboard Input (First Choice for PC)

If your mini-game primarily runs on PC, or during debugging phase for quick logic verification, keyboard is the simplest solution.

Code Example — Keyboard Input:

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

Keyboard input details:

  • Key Mapping: Support both A/D and left/right arrows simultaneously, adapting to different player habits
  • Attack Key: J key (commonly used in fighting games)
  • State Sync: Update movement direction and state machine every frame

Virtual Joystick (First Choice for Mobile)

If your mini-game runs on mobile, and is a side-scrolling fighting game or ARPG that needs precise direction control, virtual joystick is almost essential.

Key implementation points for virtual joystick:

  • Boundary Limitation: Joystick center cannot exceed background circle range
  • Direction Normalization: Return unit vector
  • Touch Release: Joystick automatically returns to center point

Code Example — Virtual Joystick Implementation:

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

Key details of virtual joystick:

  • Coordinate Conversion: Use inverseTransformPoint to convert screen coordinates to node local coordinates
  • Radius Limitation: if (distance > radius) ensures joystick doesn’t exceed background circle
  • Normalization: normalize() returns unit vector, convenient for movement script to directly multiply by speed

If you’re making flying games or parkour games (don’t need precise direction), you can use full-screen touch—finger sliding anywhere on screen controls character movement. But the input logic for these games is different from virtual joystick, needs adjustment based on game type.

Practical Case — Complete Implementation of Side-Scrolling Fighting Character Control

Integrate the previous node architecture, animation configuration, state machine, and input control into a complete side-scrolling fighting game character control solution.

Complete Code Structure:

Player (Root Node)
  ├── PlayerControl.ts (Movement Control)
  ├── PlayerStateMachine.ts (State Machine)
  └── KeyboardInput.ts (Keyboard Input)

Body (Child Node)
  ├── Sprite (Character Image)
  ├── Animation Component
  └ AttackEffect (Attack Effect Node, optional)

Complete Code Example — 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; }
}

Complete Code Example — 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);
  }
}

Complete Code Example — 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);
  }
}

Runtime Results:

  • Start game → Character idle animation plays
  • Press A/D or left/right arrow → Character moves + walk animation
  • Press J key → Character attacks + attack animation → returns to idle after 0.5 seconds
  • Release direction key → Character stops moving + idle animation

Common Problems and Solutions:

  1. Animation Switching Stutter: Check if AnimationClip frame rate is too low (recommended 12-24fps)
  2. State Residue: After attack animation ends, doesn’t return to idle, check if timer callback is executing normally
  3. Input Conflict: Character still moving while attacking, check if attack state priority is placed before movement judgment in state machine

After complete code integration, character control logic is clear: movement belongs to PlayerControl, animation belongs to StateMachine, input belongs to KeyboardInput, three scripts communicate through interfaces, without interfering with each other.

Summary

Core architecture has three layers:

  1. Node Separation: Player manages movement, Body manages animation, neither steals the show
  2. State Machine: idle, move, attack three states, clear and centralized transition conditions
  3. Input Control: PC uses keyboard, mobile uses virtual joystick, choose solution based on game type

Benefits of this three-layer architecture: changing one part doesn’t affect another. Speed up movement, animation logic doesn’t need changes; add effects to attack animation, movement script doesn’t need changes; switch input to virtual joystick, state machine logic doesn’t need changes.

Next step, you can combine with the three-layer nested architecture from Part 4 of this series “Mini-Game State Machine Design” to add more complex state management for characters—such as jump, hit, death, skill release, etc. Character state machine is just a sub-module of game state machine, extending follows same approach: define states, set transition conditions, centrally manage switching logic.

Cocos Creator Character Movement and Attack Implementation Process

From node architecture design to input control, complete implementation of side-scrolling fighting game character control

⏱️ Estimated time: 45 min

  1. 1

    Step1: Design Node Architecture

    Create a Player root node to mount movement control script, create a Body child node to mount animation component and Sprite. Player only controls x-coordinate increments/decrements, Body handles y-coordinate jumping and animation frame switching.
  2. 2

    Step2: Configure Animation Component

    Add Animation component to Body node, drag in AnimationClip resources for idle, walk, attack, etc. Set defaultClip to idle animation, check playOnLoad. In code, switch animations via animation.play('clipName').
  3. 3

    Step3: Implement State Machine

    Define State enum (Idle, Move, Attack), implement switchState method. In update, judge state switching based on isMoving and attackKeyPressed, attack state uses scheduleOnce timer to return to idle after 0.5 seconds.
  4. 4

    Step4: Connect Input Control

    Create KeyboardInput script to listen for KEY_DOWN and KEY_UP events. A/D and arrow keys control movement direction, J key triggers attack. Sync moveDir to PlayerControl and StateMachine every frame in update.
  5. 5

    Step5: Test and Optimize

    Run game to verify: idle animation plays normally, pressing direction keys makes character move and switch to walk animation, attack key triggers attack animation then automatically returns to idle, animation switching is smooth without stuttering.

FAQ

Why separate Player and Body into two nodes?
Movement and animation rhythms are inconsistent. When jumping, horizontal direction moves forward at constant speed while vertical direction draws a parabola. If using a single node, update has to calculate displacement and control animation height simultaneously, code becomes very messy. After separation, Player only manages x-coordinate, Body only manages y-coordinate and animation frames, logic is clear and maintainable.
Character is swinging sword while sliding during animation switching, how to fix?
This is caused by not calling stop before play when switching animations. The correct approach is to check if current animation is different from target animation before switching, if different then stop() first then play(). Additionally, attack state priority should be higher than move state, disable movement input during attack.
After attack animation ends, character is still playing attack frames, not returning to idle animation?
Check if timer callback is executing normally. When triggerAttack, set attackKeyPressed to true, then scheduleOnce(() => { this.attackKeyPressed = false; }, 0.1) to ensure attack signal doesn't trigger repeatedly. After attack animation plays, automatically return to idle through scheduleOnce(() => { this.switchState(State.Idle); }, 0.5).
Which input solution should I choose for PC and mobile?
PC uses keyboard (simple and direct, suitable for debugging), mobile chooses based on game type: side-scrolling fighting, ARPG that need precise direction use virtual joystick; flying games, parkour games can use full-screen touch. Virtual joystick implementation needs attention to boundary limits and direction normalization.
How to implement virtual joystick boundary limits and normalization?
Listen for TOUCH_MOVE event to get touch position, use inverseTransformPoint to convert to node local coordinates. Calculate distance from touch point to center, if distance > radius then scale proportionally to radius. Finally use normalize() to get unit vector, convenient for movement script to multiply by speed.
Why does the state machine use a timer to return to idle instead of waiting for key release?
Attack is a 'trigger once, play complete animation' action, should not be affected by key press duration. Player lightly pressing attack key and long pressing attack key should have same effect: play attack animation → return to idle after 0.5 seconds. Using timer ensures animation plays completely, won't have different behaviors due to player's different key press methods.

7 min read · Published on: May 21, 2026 · Modified on: May 21, 2026

Related Posts

Comments

Sign in with GitHub to leave a comment