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

キャラクターが画面中央に立ったまま、方向キーを押しても動かない——そんな経験はありませんか。コードを開くと、移動・アニメ再生・状態判定がすべて 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 は組み込みモジュールですが、defaultClip・clips 配列・playOnLoad など見落としやすい属性があります。
主要属性:
defaultClip:デフォルト再生(通常は待機)clips:AnimationClip の配列(待機・移動・攻撃など)currentClip:現在再生中(ランタイム)
設定手順:
- Body に Animation を追加
- AnimationClip を clips にドラッグ
- defaultClip を待機に設定
- 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 秒後に待機
- 方向キー離す → 停止 + 待機
よくある問題:
- カクつき:AnimationClip の fps が低すぎないか(12–24fps 推奨)
- 状態残留:攻撃後 idle に戻らない → タイマーコールバックを確認
- 入力競合:攻撃中も移動する → 攻撃優先の判定順を確認
統合後は、移動=PlayerControl、アニメ=StateMachine、入力=KeyboardInput と役割が明確になり、インターフェース経由で連携します。
まとめ
3 層構成が核です:
- ノード分離:Player=移動、Body=アニメ
- ステートマシン:idle / move / attack と明確な遷移
- 入力制御:PC はキーボード、モバイルはジョイスティック
1 層を変えても他に波及しません。速度調整でアニメを触らず、攻撃エフェクト追加で移動を触らず、入力をジョイスティックに替えてもステートマシンはそのまま。
次は本シリーズ第 4 回「ミニゲームステートマシン設計」の 3 層ネスト構成と組み合わせ、ジャンプ・被弾・死亡・スキルなどを足していけます。キャラのステートマシンはゲーム全体のサブモジュール——状態定義、遷移条件、一元管理という考え方は同じです。
Cocos Creator キャラクター移動・攻撃の実装手順
ノード設計から入力制御まで、横スクロールアクションのキャラクター制御を完成させる
⏱️ 目安時間: 45 分
- 1
ステップ 1: ノード構成を設計
Player ルートノードに移動制御スクリプト、Body 子ノードに Animation コンポーネントと Sprite を配置。Player は x 座標のみ、Body は y 座標のジャンプとアニメーションフレーム切り替えを担当。 - 2
ステップ 2: アニメーションコンポーネントを設定
Body ノードに Animation を追加し、待機・移動・攻撃などの AnimationClip を割り当て。defaultClip を待機に設定し playOnLoad をオン。コードから animation.play('clipName') で切り替え。 - 3
ステップ 3: ステートマシンを実装
State 列挙(Idle・Move・Attack)と switchState を定義。update 内で isMoving と attackKeyPressed から遷移を判定。攻撃状態は scheduleOnce で 0.5 秒後に idle へ戻す。 - 4
ステップ 4: 入力制御を接続
KeyboardInput で KEY_DOWN / KEY_UP を監視。A/D と左右矢印で移動、J キーで攻撃。update で毎フレーム moveDir を PlayerControl と StateMachine に同期。 - 5
ステップ 5: テストと調整
起動時に待機アニメーション、方向キーで移動+歩行アニメ、攻撃キーで攻撃アニメ→0.5 秒後に待機へ自動復帰。切り替えがスムーズか確認。
FAQ
Player と Body を 2 ノードに分ける理由は?
アニメ切替時に剣を振りながらスライドする問題は?
攻撃アニメ終了後も攻撃フレームのまま待機に戻らない?
PC とモバイルでどの入力方式を選ぶ?
バーチャルジョイスティックの境界制限と正規化は?
攻撃状態をタイマーで idle に戻す理由は?キー解放を待たないのは?
7分で読めます · 公開日: 2026年5月21日 · 更新日: 2026年7月14日
AI と Cocos 小ゲーム開発実践
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
ミニゲーム UI 素材の命名規則:透明画像、ボタン、アイコン、キャラクター素材
ミニゲーム UI 素材の命名規則をマスターしましょう。ボタン状態、アイコン分類、キャラクターアニメーションフレームの命名公式を網羅。Tencent ゲームチームの 7 つの黄金ルールで、プロジェクトファイルを一目瞭然にし、チーム協業効率を 80% 向上。
第 9 / 21 記事
次の記事
ゲームの手応えはどこから生まれるか:フラッシュ、振動、ダメージ表示、音効、パーティクル
ゲームの手応え設計の実践ガイド。19 特徴フレームワークから具体パラメータまで、フラッシュ 50〜100ms、振動 0.08 秒 40Hz、ダメージ表示と音効 12ms 同期の実装手順を解説。Cocos Creator と Unity のコード例付き
第 11 / 21 記事



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