切换语言
切换主题

Cocos 小游戏角色移动与攻击:从节点到动画的实现

你的角色站在屏幕中央,按下方向键——一动不动。打开代码一看,移动逻辑、动画播放、状态判断全部堆在一个 update 函数里,改了动画忘记改状态,改了状态忘记改速度,最后连自己都不知道角色现在应该干什么。

节点分离听起来有点抽象,说白了就是把移动和动画拆开管——Player节点负责水平位移,Body节点处理垂直跳跃和攻击动画,两者独立运行,互不干扰。这篇文章带你从节点架构开始,逐步完成动画组件配置、状态机实现、输入控制方案选型,最后给出一套完整的横版格斗角色代码。

角色节点架构设计 — 水平移动 + 垂直动画叠加

Cocos Creator 3.7 的官方教程有个容易被忽略的设计:Player 和 Body 两个节点分离。很多人(包括我一开始)觉得这没必要,直接用一个节点管移动、动画、碰撞检测不也挺简单吗?

问题出在动画和移动的节奏不一致。想象你的角色正在跳跃——水平方向匀速向前飞,垂直方向却在画抛物线。如果用单一节点,update 里既要算水平位移又要控制跳跃高度,代码写着写着就乱了。等到加入攻击动画、受击特效、甚至死亡时的旋转缩放,单一节点的逻辑基本失控。

节点分离的好处:移动归移动,动画归动画。Player节点只管 x 坐标的增减,Body节点只管 y 坐标的跳跃和动画帧切换,两者互不抢戏。

节点结构示意

Player(根节点)
  ├── PlayerControl.ts(移动逻辑)
  └── Body(子节点)
       ├── Sprite(角色图片)
       └── Animation组件

代码示例 — Player节点移动控制

// PlayerControl.ts - Player节点脚本
@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;
}

代码示例 — Body节点动画控制

// BodyControl.ts - Body节点脚本
@property(Animation)
animation: Animation = null;

jump(height: number = 100) {
  // 跳跃动画:垂直位移 + jump动画帧
  this.node.runAction(cc.jumpBy(1.0, 0, 0, height, 1));
  this.animation.play('jump');
}

attack() {
  this.animation.play('attack');
  // 攻击结束后自动回到idle
  this.scheduleOnce(() => {
    this.animation.play('idle');
  }, 0.5);
}

这样一来,移动逻辑在 Player 节点,动画逻辑在 Body 节点,改跳跃高度不用碰移动代码,改移动速度也不用去动画脚本里翻找——代码维护轻松了不少。

动画组件配置 — Animation + AnimationClip 绑定

动画组件是 Cocos Creator 的内置模块,但你可能没注意到它有几个关键属性:defaultClipclips 数组、还有个容易被忽略的 playOnLoad

Animation 组件的核心属性

  • defaultClip:默认播放的动画(通常是站立动画)
  • clips:动画资源数组(站立、行走、攻击等 AnimationClip)
  • currentClip:当前正在播放的动画(运行时状态)

配置步骤挺简单:

  1. 在 Body 节点添加 Animation 组件
  2. 把 AnimationClip 资源拖进 clips 数组
  3. 设置 defaultClip 为站立动画
  4. 勾选 playOnLoad(让角色一开始就站立)

代码示例 — 动画组件配置

// PlayerAnimation.ts - 动画控制脚本
@property(Animation)
animation: Animation = null;

@property([AnimationClip])
clips: AnimationClip[] = []; // 站立、行走、攻击、跳跃

onLoad() {
  this.animation.clips = this.clips;
  this.animation.defaultClip = this.clips[0]; // 默认站立
  this.animation.play();
}

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

有个细节:动画切换时最好先 stop()play(),尤其是攻击动画这类”一次性播放”的片段。不这么做的话,角色正在行走时按下攻击键,攻击动画可能会和行走动画抢帧——画面看起来就像角色一边挥刀一边滑步,挺怪异的。

动画切换的正确方式

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

动画组件配置完,下一步是状态机——让动画切换有规律,而不是到处散落 if (速度>0) play('move') 这类判断。

动画状态机驱动 — 待机 → 移动 → 攻击 → 返回待机

状态机的核心思想:角色在任何时刻只能处于一个状态(idle、move、attack),状态之间有明确的过渡条件。不是随便切换,而是有章有法。

我一开始也觉得状态机有点过度设计——不就是播放动画嘛,直接 play('attack') 不就完了?后来加了跳跃状态,又加了受伤状态,最后发现状态切换的判断到处散落:移动脚本里有判断,动画脚本里有判断,输入处理里还有判断。改了一处判断逻辑,另外两处忘了同步更新,角色行为就开始出bug。

状态机流转图

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

三个状态:idle(待机)、move(移动)、attack(攻击)。过渡条件:

  • idle → move:速度大于0
  • move → idle:速度为0
  • any → attack:按下攻击键
  • attack → idle:攻击动画结束

代码示例 — 状态机实现

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

状态机的关键细节:

  • 状态优先级:攻击优先于移动(攻击时不能移动)
  • 防重复切换if (currentState === newState) return 避免同一状态重复播放
  • 定时器回调:攻击动画结束后自动回到idle,而不是等按键释放

状态机把动画切换的逻辑集中到一处,修改过渡条件只改一处代码。后续要加跳跃、受伤、死亡状态,也只是往 State 枚举里加几个值,再补充过渡条件——扩展性比散乱的 if 判断强太多。

输入控制方案对比 — 键盘 vs 触摸 vs 虚拟摇杆

三种方案各有适用场景,选错了会让操作手感大打折扣。

方案适用场景优点缺点实现复杂度
键盘PC端、调试阶段简单直接,响应快手机端不可用
触摸全屏移动游戏(飞行、跑酷)操作自然,无UI遮挡方向精度低,容易误触
虚拟摇杆ARPG、格斗游戏方向精确,360度控制UI占用屏幕空间

键盘输入(PC端首选)

如果你的小游戏主要在PC端运行,或者调试阶段快速验证逻辑,键盘是最简单的方案。

代码示例 — 键盘输入

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

键盘输入的细节:

  • 按键映射:A/D 和 左/右箭头同时支持,适配不同玩家习惯
  • 攻击键:J 键(格斗游戏常用)
  • 状态同步:每帧更新移动方向和状态机

虚拟摇杆(移动端首选)

如果你的小游戏是手机端运行,而且是横版格斗、ARPG这类需要精确方向控制的游戏,虚拟摇杆几乎是必选项。

虚拟摇杆的实现要点:

  • 边界限制:摇杆中心不能超出背景圆的范围
  • 方向归一化:返回单位向量
  • 触摸释放:摇杆自动回到中心点

代码示例 — 虚拟摇杆实现

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

虚拟摇杆的关键细节:

  • 坐标转换:用 inverseTransformPoint 把屏幕坐标转为节点本地坐标
  • 半径限制if (distance > radius) 确保摇杆不超出背景圆
  • 归一化normalize() 返回单位向量,方便移动脚本直接乘速度

如果你做的是飞行类、跑酷类游戏(不需要精确方向),可以用全屏触摸——手指在屏幕任意位置滑动都控制角色移动。但这类游戏的输入逻辑和虚拟摇杆不同,需要根据游戏类型调整。

实战案例 — 横版格斗角色控制完整实现

把前面的节点架构、动画配置、状态机、输入控制整合成一个完整的横版格斗角色控制方案。

完整代码结构

Player(根节点)
  ├── PlayerControl.ts(移动控制)
  ├── PlayerStateMachine.ts(状态机)
  └── KeyboardInput.ts(键盘输入)

Body(子节点)
  ├── Sprite(角色图片)
  ├── Animation组件
  └ AttackEffect(攻击特效节点,可选)

完整代码示例 — 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; }
}

完整代码示例 — 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);
  }
}

完整代码示例 — 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);
  }
}

运行效果

  • 启动游戏 → 角色站立动画播放
  • 按下 A/D 或 左/右箭头 → 角色移动 + 行走动画
  • 按下 J键 → 角色攻击 + 攻击动画 → 0.5秒后回到站立
  • 释放方向键 → 角色停止移动 + 站立动画

常见问题与解决方案

  1. 动画切换卡顿:检查 AnimationClip 的帧率是否过低(建议 12-24fps)
  2. 状态残留:攻击动画结束后没有回到idle,检查定时器回调是否正常执行
  3. 输入冲突:攻击时角色还在移动,检查状态机里是否把攻击状态优先级放在移动判断之前

完整代码整合后,角色控制逻辑清晰:移动归 PlayerControl,动画归 StateMachine,输入归 KeyboardInput,三个脚本通过接口通信,互不干扰。

总结

核心架构就三层:

  1. 节点分离:Player管移动,Body管动画,互不抢戏
  2. 状态机:idle、move、attack三个状态,过渡条件清晰集中
  3. 输入控制:PC端用键盘,移动端用虚拟摇杆,根据游戏类型选方案

这三层架构的好处:改一处不影响另一处。移动速度调快了,动画逻辑不用改;攻击动画加特效,移动脚本不用改;输入换成虚拟摇杆,状态机逻辑不用改。

下一步,你可以结合本系列第4篇《小游戏状态机设计》的三层嵌套架构,为角色添加更复杂的状态管理——比如跳跃、受伤、死亡、技能释放等。角色状态机只是游戏状态机的一个子模块,扩展起来思路是一样的:定义状态、设置过渡条件、集中管理切换逻辑。

Cocos Creator 角色移动与攻击实现流程

从节点架构设计到输入控制,完整实现横版格斗角色控制

⏱️ 预计耗时: 45 分钟

  1. 1

    步骤1: 设计节点架构

    创建 Player 根节点挂载移动控制脚本,创建 Body 子节点挂载动画组件和 Sprite。Player 只控制 x 坐标的增减,Body 处理 y 坐标的跳跃和动画帧切换。
  2. 2

    步骤2: 配置动画组件

    在 Body 节点添加 Animation 组件,拖入站立、行走、攻击等 AnimationClip 资源。设置 defaultClip 为站立动画,勾选 playOnLoad。在代码中通过 animation.play('clipName') 切换动画。
  3. 3

    步骤3: 实现状态机

    定义 State 枚举(Idle、Move、Attack),实现 switchState 方法。在 update 中根据 isMoving 和 attackKeyPressed 判断状态切换,攻击状态用 scheduleOnce 定时器在 0.5 秒后回到 idle。
  4. 4

    步骤4: 接入输入控制

    创建 KeyboardInput 脚本监听 KEY_DOWN 和 KEY_UP 事件。A/D 和左右箭头控制移动方向,J 键触发攻击。在 update 中每帧同步 moveDir 到 PlayerControl 和 StateMachine。
  5. 5

    步骤5: 测试与调优

    运行游戏验证:站立动画播放正常,方向键按下角色移动并切换行走动画,攻击键触发攻击动画后自动回到站立,动画切换流畅无卡顿。

常见问题

为什么要把 Player 和 Body 分成两个节点?
移动和动画的节奏不一致。跳跃时水平方向匀速向前,垂直方向画抛物线,如果用单一节点 update 里既要算位移又要控制动画高度,代码会非常混乱。分离后 Player 只管 x 坐标,Body 只管 y 和动画帧,逻辑清晰易维护。
动画切换时角色一边挥刀一边滑步,怎么解决?
这是动画切换没有先 stop 再 play 导致的。正确做法是在切换动画前判断当前动画是否和目标动画相同,如果不同则先 stop() 再 play()。另外,攻击状态优先级要高于移动状态,攻击时禁用移动输入。
攻击动画结束后角色还在播放攻击帧,没有回到站立动画?
检查定时器回调是否正常执行。在 triggerAttack 时设置 attackKeyPressed 为 true,然后 scheduleOnce(() => { this.attackKeyPressed = false; }, 0.1) 确保攻击信号不会重复触发。攻击动画播放后,通过 scheduleOnce(() => { this.switchState(State.Idle); }, 0.5) 自动回到站立。
PC端和移动端应该选择哪种输入方案?
PC端用键盘(简单直接,适合调试),移动端根据游戏类型选择:横版格斗、ARPG 需要精确方向的用虚拟摇杆;飞行类、跑酷类可以用全屏触摸。虚拟摇杆实现要注意边界限制和方向归一化。
虚拟摇杆的边界限制和归一化怎么实现?
监听 TOUCH_MOVE 事件获取触摸位置,用 inverseTransformPoint 转为节点本地坐标。计算触摸点到中心的距离 distance,如果 distance > radius 则等比例缩放到 radius。最后用 normalize() 获得单位向量,方便移动脚本乘以速度使用。
状态机的攻击状态为什么要用定时器回到 idle,而不是等按键释放?
攻击是「触发一次,播放完整动画」的动作,不应该受按键按下时长影响。玩家轻按攻击键和长按攻击键,效果应该一样:播放攻击动画 → 0.5 秒后回到站立。用定时器可以确保动画播放完整,不会因为玩家按键方式不同导致行为差异。

8 分钟阅读 · 发布于: 2026年5月21日 · 修改于: 2026年5月25日

相关文章

BetterLink

想持续收到这个主题的更新?

你可以直接关注作者更新、订阅 RSS,或者继续沿着系列入口往下读,避免下次又回到搜索结果重新找。

关注公众号

评论

使用 GitHub 账号登录后即可评论