テーマを切り替える

Cocos Creator キャラクター移動と攻撃:ノードからアニメーションまでの実装

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

キャラクターが画面中央に立ったまま、方向キーを押しても動かない——そんな経験はありませんか。コードを開くと、移動・アニメ再生・状態判定がすべて 1 つの update に詰め込まれています。アニメを直して状態を忘れ、状態を直して速度を忘れ、最終的にはキャラが今何をすべきか自分でも分からなくなります。

ノード分離とは、要するに移動とアニメを分けて管理することです。Player が水平移動、Body が垂直ジャンプと攻撃アニメを担当し、互いに干渉しません。本記事ではノード構成から始め、アニメーション設定、ステートマシン、入力方式の選び方まで進め、横スクロールアクション向けの完全なキャラクター制御コードを提供します。

キャラクターノード構成 — 水平移動 + 垂直アニメの重ね合わせ

Cocos Creator 3.7 公式チュートリアルには、見落とされがちな設計があります。Player と Body を分離することです。最初は私も「1 ノードで移動・アニメ・当たり判定を全部やれば十分では」と思っていました。

問題は、アニメと移動のリズムが一致しないこと。ジャンプ中、水平方向は等速、垂直方向は放物線です。単一ノードでは 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

Animation は組み込みモジュールですが、defaultClipclips 配列・playOnLoad など見落としやすい属性があります。

主要属性

  • 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') のような散在した判定を整理します。

アニメーションステートマシン — 待機 → 移動 → 攻撃 → 待機

ステートマシンの考え方は、キャラは常に 1 状態(idle / move / attack)にあり、遷移条件が明確であること。漫然と切り替えるのではなく、ルールに従います。

最初は過剰設計に感じました——play('attack') すればいいのでは? と。ジャンプ・被弾を足すうち、移動スクリプト・アニメスクリプト・入力処理に判定が散らばり、1 箇所直して他を忘れるとバグが出ます。

遷移図

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

3 状態: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 へ。キー解放を待たない

ロジックが 1 箇所に集約され、ジャンプ・被弾・死亡を足すときも State 列挙と遷移条件を追加するだけで済みます。

入力制御の比較 — キーボード vs タッチ vs バーチャルジョイスティック

方式を間違えると操作感が大きく損なわれます。

方式向くシーンメリットデメリット実装難易度
キーボードPC・デバッグシンプル、応答速いスマホ不可
タッチ全画面移動(シューティング・ラン)自然、UI 非占有方向精度低、誤操作
バーチャルジョイスティックARPG・アクション方向精密、360°画面を占有

キーボード入力(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(アクション系で一般的)
  • 同期:毎フレーム moveDir とステートマシンを更新

バーチャルジョイスティック(モバイル向け)

スマホ向け横スクロールアクション・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 でローカル座標へ
  • 半径制限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 の fps が低すぎないか(12–24fps 推奨)
  2. 状態残留:攻撃後 idle に戻らない → タイマーコールバックを確認
  3. 入力競合:攻撃中も移動する → 攻撃優先の判定順を確認

統合後は、移動=PlayerControl、アニメ=StateMachine、入力=KeyboardInput と役割が明確になり、インターフェース経由で連携します。

まとめ

3 層構成が核です:

  1. ノード分離:Player=移動、Body=アニメ
  2. ステートマシン:idle / move / attack と明確な遷移
  3. 入力制御:PC はキーボード、モバイルはジョイスティック

1 層を変えても他に波及しません。速度調整でアニメを触らず、攻撃エフェクト追加で移動を触らず、入力をジョイスティックに替えてもステートマシンはそのまま。

次は本シリーズ第 4 回「ミニゲームステートマシン設計」の 3 層ネスト構成と組み合わせ、ジャンプ・被弾・死亡・スキルなどを足していけます。キャラのステートマシンはゲーム全体のサブモジュール——状態定義、遷移条件、一元管理という考え方は同じです。

Cocos Creator キャラクター移動・攻撃の実装手順

ノード設計から入力制御まで、横スクロールアクションのキャラクター制御を完成させる

⏱️ 目安時間: 45 分

  1. 1

    ステップ 1: ノード構成を設計

    Player ルートノードに移動制御スクリプト、Body 子ノードに Animation コンポーネントと 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: テストと調整

    起動時に待機アニメーション、方向キーで移動+歩行アニメ、攻撃キーで攻撃アニメ→0.5 秒後に待機へ自動復帰。切り替えがスムーズか確認。

FAQ

Player と Body を 2 ノードに分ける理由は?
移動とアニメーションのリズムが一致しないため。ジャンプ中は水平方向が等速、垂直方向が放物線。単一ノードで 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 が radius を超えたら等比で radius 内に収める。normalize() で単位ベクトルを得て、移動スクリプトで速度を掛ける。
攻撃状態をタイマーで idle に戻す理由は?キー解放を待たないのは?
攻撃は「1 回トリガーでアニメを最後まで再生」する動作。押下時間に依存すべきでない。軽押しも長押しも同じ:攻撃アニメ→0.5 秒後に待機。タイマーで再生完了を保証し、入力の差による挙動差を防ぐ。

7分で読めます · 公開日: 2026年5月21日 · 更新日: 2026年7月14日

関連記事

コメント

GitHubアカウントでログインしてコメントできます

Easton BlogEaston Blog