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の組み込みモジュールですが、見落とされがちな重要な属性がいくつかあります。defaultClip、clips配列、そして忘れがちなplayOnLoadです。
Animationコンポーネントの主要属性:
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);
}
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秒後に待機に戻る
- 方向キーを解放 → キャラクター移動停止 + 待機アニメーション
よくある問題と解決策:
- アニメーション切り替えのカクつき:AnimationClipのフレームレートが低すぎないか確認(12-24fps推奨)
- 状態の残留:攻撃アニメーション終了後にidleに戻らない、タイマーコールバックが正常に実行されているか確認
- 入力の競合:攻撃中もキャラクターが移動している、ステートマシン内で攻撃状態の優先度が移動判断より前にあるか確認
完全なコード統合後、キャラクター制御ロジックは明確になります。移動はPlayerControl、アニメーションはStateMachine、入力はKeyboardInputが担当し、3つのスクリプトはインターフェースを通じて通信し、互いに干渉しません。
まとめ
核となるアーキテクチャは3層です:
- ノード分離:Playerは移動、Bodyはアニメーションを管理、互いに干渉しない
- ステートマシン:idle、move、attackの3つの状態、遷移条件は明確で集中管理
- 入力制御:PCはキーボード、モバイルはバーチャルジョイスティック、ゲームタイプに応じて選択
この3層アーキテクチャの利点は、1箇所を変更しても他に影響しないことです。移動速度を上げてもアニメーションロジックを修正する必要がなく、攻撃アニメーションにエフェクトを追加しても移動スクリプトを修正する必要がなく、入力をバーチャルジョイスティックに変更してもステートマシンロジックを修正する必要がありません。
次のステップとして、本シリーズ第4回「ミニゲームステートマシン設計」の3層ネストアーキテクチャと組み合わせ、キャラクターにさらに複雑な状態管理を追加できます。ジャンプ、被弾、死亡、スキル発動などです。キャラクターステートマシンはゲームステートマシンの1つのサブモジュールに過ぎず、拡張の考え方は同じです。状態を定義し、遷移条件を設定し、切り替えロジックを一元管理します。
Cocos Creator キャラクター移動・攻撃実装フロー
ノードアーキテクチャ設計から入力制御まで、横スクロールアクションゲームのキャラクター制御を完全実装
⏱️ 目安時間: 45 分
- 1
ステップ1: ノードアーキテクチャを設計
Playerルートノードを作成して移動制御スクリプトをアタッチし、Body子ノードを作成してアニメーションコンポーネントと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: テストとチューニング
ゲームを実行して検証:待機アニメーションが正常に再生、方向キーを押すとキャラクターが移動して移動アニメーションに切り替わる、攻撃キーで攻撃アニメーションがトリガーされた後自動的に待機に戻る、アニメーション切り替えがスムーズでカクつきがない。
FAQ
なぜPlayerとBodyを2つのノードに分ける必要があるのか?
アニメーション切り替え時にキャラクターが剣を振りながらスライドする現象が発生する、どう解決する?
攻撃アニメーション終了後もキャラクターが攻撃フレームを再生し続け、待機アニメーションに戻らない?
PC版とモバイル版ではどの入力スキームを選ぶべきか?
バーチャルジョイスティックの境界制限と正規化はどう実装する?
ステートマシンの攻撃状態はなぜタイマーでidleに戻すのか、キー解放を待つのではないのはなぜ?
5 min read · 公開日: 2026年5月21日 · 更新日: 2026年5月21日
AI と Cocos 小ゲーム開発実践
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
AIゲーム効果音プロンプト完全ガイド:攻撃・拾得・勝利・失敗の効果音記述術
ElevenLabs、SFX Engine、AudioLDM、MusicGenの4大AI効果音生成プラットフォームを比較。攻撃・拾得・勝利・失敗の4種類の効果音プロンプトテンプレート(日英対照)とCocos Creator統合フロー、デバッグのコツを提供します。
第 7 / 9 記事
次の記事
ゲームの手触りはどこから来るのか:フラッシュ、振動、浮遊文字、音効、粒子フィードバック
ゲームの手触り設計実践:19特徴フレームワークから具体的パラメータまで、フラッシュ50-100ms、振動0.08秒40Hz、浮遊文字と音効12ms同期の完全実装方案を詳解、Cocos CreatorとUnityのコード例付き
第 9 / 9 記事
関連記事
インディーゲーム開発:まずゲームプレイを検証し、それからシステムを構築する(MVP実践ガイド)
インディーゲーム開発:まずゲームプレイを検証し、それからシステムを構築する(MVP実践ガイド)
ミニゲームのステートマシン設計:ホーム画面から戦闘、決算までの完全な流れ
ミニゲームのステートマシン設計:ホーム画面から戦闘、決算までの完全な流れ
AI で Cocos シーン説明書を生成:コードアシスタントにゲームを理解させる方法
コメント
GitHubアカウントでログインしてコメントできます