테마 전환

Cocos 미니게임 캐릭터 이동과 공격: 노드에서 애니메이션까지 구현하기

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

캐릭터가 화면 중앙에 서 있습니다. 방향키를 눌렀는데 아무 움직임이 없습니다. 코드를 열어 보니 이동 로직, 애니메이션 재생, 상태 판단이 모두 하나의 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 연결

Animation 컴포넌트는 Cocos Creator의 내장 모듈입니다. 하지만 몇 가지 핵심 속성은 놓치기 쉽습니다. defaultClip, clips 배열, 그리고 자주 간과되는 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')을 호출하면 되지 않을까 싶었습니다. 하지만 점프 상태를 추가하고, 다시 피격 상태까지 넣고 나니 상태 전환 판단이 곳곳에 흩어졌습니다. 이동 스크립트에도 판단이 있고, 애니메이션 스크립트에도 판단이 있고, 입력 처리에도 판단이 있었습니다. 한 곳의 판단 로직을 고쳤는데 나머지 두 곳을 함께 수정하지 않으면 캐릭터 동작에 버그가 생기기 시작했습니다.

상태 머신 흐름도:

[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편인 “미니게임 상태 머신 설계”의 3단 중첩 구조를 결합해 캐릭터에 더 복잡한 상태 관리를 추가할 수 있습니다. 예를 들면 점프, 피격, 사망, 스킬 발동 같은 상태입니다. 캐릭터 상태 머신은 게임 상태 머신의 하위 모듈일 뿐입니다. 확장 방식도 같습니다. 상태를 정의하고, 전환 조건을 설정하고, 전환 로직을 한곳에서 관리하면 됩니다.

Cocos Creator 캐릭터 이동과 공격 구현 절차

노드 구조 설계부터 입력 제어까지, 횡스크롤 격투 캐릭터 제어를 완성합니다.

⏱️ Estimated time: 45 min

  1. 1

    Step 1: 노드 구조 설계

    Player 루트 노드를 만들고 이동 제어 스크립트를 붙입니다. Body 자식 노드에는 Animation 컴포넌트와 Sprite를 둡니다. Player는 x 좌표 증감만 제어하고, Body는 y 좌표 점프와 애니메이션 프레임 전환을 처리합니다.
  2. 2

    Step 2: 애니메이션 컴포넌트 설정

    Body 노드에 Animation 컴포넌트를 추가하고, 대기, 걷기, 공격 등의 AnimationClip 리소스를 넣습니다. defaultClip은 대기 애니메이션으로 설정하고 playOnLoad를 켭니다. 코드에서는 animation.play('clipName')으로 애니메이션을 전환합니다.
  3. 3

    Step 3: 상태 머신 구현

    State 열거형(Idle, Move, Attack)을 정의하고 switchState 메서드를 구현합니다. update에서 isMoving과 attackKeyPressed에 따라 상태를 전환합니다. 공격 상태는 scheduleOnce 타이머로 0.5초 뒤 idle로 돌아가게 합니다.
  4. 4

    Step 4: 입력 제어 연결

    KeyboardInput 스크립트를 만들어 KEY_DOWN과 KEY_UP 이벤트를 수신합니다. A/D와 좌우 화살표는 이동 방향을 제어하고, J 키는 공격을 트리거합니다. update에서는 매 프레임 moveDir을 PlayerControl과 StateMachine에 동기화합니다.
  5. 5

    Step 5: 테스트와 조정

    게임을 실행해 대기 애니메이션이 정상 재생되는지, 방향키를 누르면 캐릭터가 이동하며 걷기 애니메이션으로 바뀌는지, 공격 키를 누르면 공격 애니메이션 뒤 자동으로 대기 상태로 돌아오는지 확인합니다.

FAQ

왜 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초 뒤 대기 상태로 돌아가는 흐름입니다. 타이머를 쓰면 키 입력 방식 차이로 동작이 달라지는 일을 막고 애니메이션을 끝까지 재생할 수 있습니다.

5분 읽기 · 게시일: 2026년 5월 21일 · 수정일: 2026년 10월 21일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog