言語を切り替える
テーマを切り替える

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

あなたのキャラクターが画面中央に立っています。方向キーを押しても動きません。コードを確認すると、移動ロジック、アニメーション再生、状態判断がすべて一つのupdate関数に詰め込まれています。アニメーションを修正して状態を修正し忘れ、状態を修正して速度を修正し忘れ、最後には自分でもキャラクターが今何をすべきかわからなくなります。

ノード分離は少し抽象的に聞こえるかもしれませんが、要するに移動とアニメーションを分けて管理することです。Playerノードは水平変位を担当し、Bodyノードは垂直ジャンプと攻撃アニメーションを処理し、両者は独立して動作し、互いに干渉しません。この記事では、ノードアーキテクチャから始めて、アニメーションコンポーネントの設定、ステートマシンの実装、入力制御スキームの選択まで段階的に進め、最後に横スクロールアクションゲームの完全なキャラクター制御コードを提供します。

キャラクターノードアーキテクチャ設計 — 水平移動 + 垂直アニメーション重畳

Cocos Creator 3.7の公式チュートリアルには見落とされがちな設計があります。PlayerとBodyの2つのノードを分離することです。多くの人(私を含め最初は)はこれが不要だと思い、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 バインド

アニメーションコンポーネントは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);
}

1つ重要なディテールがあります。アニメーション切り替え時は、特に攻撃アニメーションのような「一度だけ再生」するクリップでは、先に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箇所の判断ロジックを修正し、他の2箇所の同期更新を忘れると、キャラクターの動作にバグが出始めます。

ステートマシン遷移図

[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列挙型に値を追加し、遷移条件を補足するだけで済みます。散らばったif判断よりも拡張性が遥かに高いです。

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

3つのスキームそれぞれに適用シーンがあり、選択を間違えると操作感が大幅に損なわれます。

スキーム適用シーンメリットデメリット実装難易度
キーボード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が担当し、3つのスクリプトはインターフェースを通じて通信し、互いに干渉しません。

まとめ

核となるアーキテクチャは3層です:

  1. ノード分離:Playerは移動、Bodyはアニメーションを管理、互いに干渉しない
  2. ステートマシン:idle、move、attackの3つの状態、遷移条件は明確で集中管理
  3. 入力制御:PCはキーボード、モバイルはバーチャルジョイスティック、ゲームタイプに応じて選択

この3層アーキテクチャの利点は、1箇所を変更しても他に影響しないことです。移動速度を上げてもアニメーションロジックを修正する必要がなく、攻撃アニメーションにエフェクトを追加しても移動スクリプトを修正する必要がなく、入力をバーチャルジョイスティックに変更してもステートマシンロジックを修正する必要がありません。

次のステップとして、本シリーズ第4回「ミニゲームステートマシン設計」の3層ネストアーキテクチャと組み合わせ、キャラクターにさらに複雑な状態管理を追加できます。ジャンプ、被弾、死亡、スキル発動などです。キャラクターステートマシンはゲームステートマシンの1つのサブモジュールに過ぎず、拡張の考え方は同じです。状態を定義し、遷移条件を設定し、切り替えロジックを一元管理します。

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: テストとチューニング

    ゲームを実行して検証:待機アニメーションが正常に再生、方向キーを押すとキャラクターが移動して移動アニメーションに切り替わる、攻撃キーで攻撃アニメーションがトリガーされた後自動的に待機に戻る、アニメーション切り替えがスムーズでカクつきがない。

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を計算し、distance > radiusの場合はradiusに等比縮小します。最後にnormalize()で単位ベクトルを取得し、移動スクリプトで速度を掛けて使用しやすくします。
ステートマシンの攻撃状態はなぜタイマーでidleに戻すのか、キー解放を待つのではないのはなぜ?
攻撃は「一度トリガー、完全なアニメーション再生」というアクションであり、キー押下時間の影響を受けるべきではありません。プレイヤーが軽く攻撃キーを押しても長押ししても、効果は同じにする必要があります。攻撃アニメーションを再生 → 0.5秒後に待機に戻る。タイマーを使うことで、アニメーションが完全に再生され、プレイヤーのキー押下方法の違いによる動作の差異を防げます。

5 min read · 公開日: 2026年5月21日 · 更新日: 2026年5月21日

関連記事

コメント

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