BabylonJS 16회차 : 애니메이션 이벤트/상태머신 흉내
캐릭터 애니메이션을 붙여 놓고 나면, 다음 단계는 “애니메이션과 게임 로직을 어떻게 연결할 것인가”입니다. 예를 들어 달리기 애니메이션이 재생 중인데 입력이 끊기면 즉시 멈추고 Idle로 돌아가야 하고, 공격/점프처럼 “한 번 재생하고 끝나는” 동작은 끝나는 순간을 감지해서 다음 상태로 전환해야 합니다.
이번 글은 상태(state)라는 간단한 기준과 onAnimationEnd 계열 이벤트를 이용해, 복잡한 상태머신 라이브러리 없이도 “상태머신 흉내”를 내는 방법을 초보자 관점에서 정리합니다.
목차
- 이번 회차 목표(애니메이션-로직 연결)
- 핵심 개념: onAnimationEnd, 상태(state)
- 산출물 1) 간단 상태 전이 표
- 산출물 2) 구현(Idle ↔ Run + onAnimationEnd 활용)
- 실행 단계(실습 체크리스트)
- 추가로 생각해볼 점
- 블로그 최적화 정보
이번 회차 목표(애니메이션-로직 연결)
- 입력/속도 같은 “게임 로직”을 기준으로 애니메이션을 선택하고, 매 프레임 무작정 재생하지 않도록 구조를 잡습니다.
- 공격/점프처럼 “한 번 재생 후 끝”나는 애니메이션은
onAnimationEnd이벤트로 종료 시점을 감지해 다음 상태로 전환합니다. - 실습 과제: 달리다가 멈추면
Idle로 복귀(상태 전이 + 코드 구현).
핵심 개념: onAnimationEnd, 상태(state)
1) 상태(state)는 “지금 캐릭터가 무엇을 하고 있는가”를 한 단어로 저장합니다
상태머신이 어려워 보이는 이유는 상태가 많아지고 전이가 늘어나기 때문입니다. 하지만 시작은 단순합니다. 일단 Idle(정지)과 Run(달리기) 두 상태만 둬도 로직이 급격히 깔끔해집니다.
state = "idle"이면: Idle 애니메이션을 루프 재생하고, 이동 입력이 생기면 Run으로 전이합니다.state = "run"이면: Run 애니메이션을 루프 재생하고, 이동 입력이 사라지면 Idle로 전이합니다.
핵심은 “현재 상태를 저장”하고, “상태가 바뀔 때만 애니메이션을 바꾼다”는 규칙입니다. 이 규칙 하나로, 같은 애니메이션을 매 프레임 다시 재생하는 실수를 예방할 수 있습니다.
2) onAnimationEnd는 “애니메이션이 끝나는 순간”을 신호로 받는 장치입니다
Babylon.js는 애니메이션이 끝날 때 알림을 받을 수 있도록 Observable(구독 가능한 이벤트)을 제공합니다. 예를 들어 Animatable에는 애니메이션 종료를 알리는 onAnimationEndObservable이 있습니다. :contentReference[oaicite:0]{index=0}
또한 glTF/FBX 등을 불러오면 보통 AnimationGroup 형태로 애니메이션이 제공되는데, 그룹에도 종료를 감지할 수 있는 Observable이 준비되어 있습니다. (루프 재생이면 “끝”이 없으므로 종료 이벤트가 발생하지 않는 점이 중요합니다.) :contentReference[oaicite:1]{index=1}
3) Observable을 쓰면 “로직이 애니메이션을 기다릴 수” 있습니다
애니메이션은 시간에 따라 진행되고, 로직은 즉시 실행됩니다. 두 흐름을 연결하려면 “이벤트/옵저버” 방식이 가장 자연스럽습니다. Babylon.js 문서에서도 다양한 시스템이 Observables 패턴을 사용한다고 안내합니다. :contentReference[oaicite:2]{index=2}
산출물 1) 간단 상태 전이 표
| 현재 상태 | 조건(입력/이벤트) | 다음 상태 | 애니메이션 처리 |
|---|---|---|---|
Idle |
이동 입력이 있음(속도 > 임계값) | Run |
Run 루프 시작, Idle 정지 |
Run |
이동 입력이 없음(속도 ≤ 임계값) | Idle |
Idle 루프 시작, Run 정지 |
Idle/Run |
공격 키 입력(예: Space) | Action |
Attack 1회 재생 시작, 종료 이벤트 구독 |
Action |
Attack 애니메이션 종료(onAnimationEnd) | Idle 또는 Run |
입력 상태에 따라 Idle/Run 복귀 |
이번 회차의 실습 목표(달리다 멈추면 Idle)는 위 표의 1~2행이 핵심이며, onAnimationEnd는 3~4행처럼 “끝나는 동작”을 다룰 때 특히 강력합니다.
산출물 2) 구현(Idle ↔ Run + onAnimationEnd 활용)
구현 전략
- 상태는 문자열/열거형(enum) 하나로 관리합니다:
idle,run,action. - 애니메이션은 “상태 전이 시점에만” 바꿉니다. (매 프레임 재생 금지)
idle/run은 루프 재생,action(Attack 등)은 1회 재생 후onAnimationEnd로 복귀합니다.
애니메이션 이름 확인(초보자 필수 팁)
모델(glTF 등)을 불러오면 애니메이션 그룹 이름이 파일마다 다릅니다. 먼저 그룹 이름을 출력해서 “내 모델이 어떤 이름을 쓰는지” 확인하는 습관이 중요합니다.
// result는 SceneLoader.ImportMeshAsync(...) 반환값이라고 가정
const names = result.animationGroups.map(g => g.name);
console.log("AnimationGroups:", names);
전체 예제 코드(상태 전이 + onAnimationEnd)
아래 예제는 “이동 입력이 있으면 Run, 없으면 Idle”을 상태로 결정하고, Space를 누르면 Attack을 1회 재생한 뒤 종료 이벤트로 원래 상태로 복귀합니다. AnimationGroup의 종료 감지에는 버전/상황에 따라 차이가 있을 수 있어, 그룹 종료 Observable이 있으면 그것을 사용하고, 없으면 onAnimationEndObservable를 타깃 수 기준으로 합산하는 방식으로 폴백합니다. (AnimationGroup의 종료 관련 Observable과 그룹 애니메이션 개념은 Babylon.js 문서에서 확인할 수 있습니다.) :contentReference[oaicite:3]{index=3}
// Babylon.js 기본 초기화(캔버스/엔진/씬)는 이미 되어 있다고 가정합니다.
// 아래는 핵심 로직에 집중한 예시입니다.
const State = {
Idle: "idle",
Run: "run",
Action: "action",
};
function mapGroupsByName(animationGroups) {
const map = new Map();
for (const g of animationGroups) map.set(g.name.toLowerCase(), g);
return map;
}
// AnimationGroup이 "전체로" 끝났을 때 한 번만 콜백을 실행하고 싶을 때 사용합니다.
function addGroupEndOnce(group, callback) {
// 1) 그룹 종료 Observable이 있으면 가장 깔끔합니다(환경에 따라 제공 여부가 다를 수 있습니다).
if (group.onAnimationGroupEndObservable && group.onAnimationGroupEndObservable.addOnce) {
group.onAnimationGroupEndObservable.addOnce(callback);
return;
}
// 2) 폴백: onAnimationEndObservable(타깃 애니메이션 종료) 횟수를 합산해서 "그룹 종료"처럼 취급합니다.
const total = (group.targetedAnimations && group.targetedAnimations.length) ? group.targetedAnimations.length : 1;
let ended = 0;
const obs = group.onAnimationEndObservable.add(() => {
ended += 1;
if (ended >= total) {
group.onAnimationEndObservable.remove(obs);
callback();
}
});
}
class CharacterAnimator {
constructor(scene, groupMap, options = {}) {
this.scene = scene;
this.groupMap = groupMap;
this.state = State.Idle;
this.moveIntent = false; // "움직이려는가"
this.speedThreshold = options.speedThreshold ?? 0.1;
this.idleName = (options.idleName ?? "idle").toLowerCase();
this.runName = (options.runName ?? "run").toLowerCase();
this.attackName = (options.attackName ?? "attack").toLowerCase();
// 선택: 전환을 부드럽게 하고 싶으면 블렌딩을 켭니다(프로젝트에 맞게 조정).
// this.scene.animationPropertiesOverride = new BABYLON.AnimationPropertiesOverride();
// this.scene.animationPropertiesOverride.enableBlending = true;
// this.scene.animationPropertiesOverride.blendingSpeed = 0.08;
this._playLoop(this.idleName);
}
setMoveIntent(isMoving) {
this.moveIntent = isMoving;
this._updateLocomotionState();
}
triggerAttack() {
// Action 중에는 중복 공격을 막습니다(원하면 큐잉/캔슬을 추가할 수 있음).
if (this.state === State.Action) return;
const returnState = this.moveIntent ? State.Run : State.Idle;
this._transitionToAction(this.attackName, returnState);
}
_updateLocomotionState() {
// Action 상태에서는 locomotion(Idle/Run) 전환을 잠시 잠급니다.
if (this.state === State.Action) return;
const next = this.moveIntent ? State.Run : State.Idle;
if (next === this.state) return;
if (next === State.Run) this._transitionToRun();
if (next === State.Idle) this._transitionToIdle();
}
_getGroup(nameLower) {
const g = this.groupMap.get(nameLower);
if (!g) {
console.warn("AnimationGroup not found:", nameLower, "available:", [...this.groupMap.keys()]);
}
return g;
}
_stopAll() {
for (const g of this.groupMap.values()) {
try { g.stop(); } catch (e) {}
}
}
_playLoop(nameLower) {
const g = this._getGroup(nameLower);
if (!g) return;
// 동일 루프를 다시 시작하지 않도록 상태로 제어합니다.
this._stopAll();
try { g.reset(); } catch (e) {}
// start(loop, speedRatio, from, to)
g.start(true, 1.0);
}
_playOnce(nameLower, onEnd) {
const g = this._getGroup(nameLower);
if (!g) return;
this._stopAll();
try { g.reset(); } catch (e) {}
addGroupEndOnce(g, onEnd);
// 루프=false로 시작해야 "끝"이 생깁니다(루프면 종료 이벤트가 오지 않습니다).
g.start(false, 1.0);
}
_transitionToIdle() {
this.state = State.Idle;
this._playLoop(this.idleName);
}
_transitionToRun() {
this.state = State.Run;
this._playLoop(this.runName);
}
_transitionToAction(actionNameLower, returnState) {
this.state = State.Action;
this._playOnce(actionNameLower, () => {
// 애니메이션 종료 순간에 입력 상태를 다시 보고 복귀합니다.
if (returnState === State.Run) this._transitionToRun();
else this._transitionToIdle();
});
}
}
// -------------------- 사용 예시 --------------------
// 1) 모델 로드
// const result = await BABYLON.SceneLoader.ImportMeshAsync("", "/assets/", "character.glb", scene);
// const groupMap = mapGroupsByName(result.animationGroups);
// 2) 이름은 모델에 맞게 수정하세요(예: "Idle", "Run", "Attack" 등)
const groupMap = mapGroupsByName(result.animationGroups);
const animator = new CharacterAnimator(scene, groupMap, {
idleName: "Idle",
runName: "Run",
attackName: "Attack",
});
// 3) 입력 처리(예시: WASD로 이동 의도, Space로 공격)
const input = { w:false, a:false, s:false, d:false };
window.addEventListener("keydown", (e) => {
if (e.code === "KeyW") input.w = true;
if (e.code === "KeyA") input.a = true;
if (e.code === "KeyS") input.s = true;
if (e.code === "KeyD") input.d = true;
if (e.code === "Space") animator.triggerAttack();
});
window.addEventListener("keyup", (e) => {
if (e.code === "KeyW") input.w = false;
if (e.code === "KeyA") input.a = false;
if (e.code === "KeyS") input.s = false;
if (e.code === "KeyD") input.d = false;
});
// 4) 매 프레임 "이동 의도"만 계산해서 상태 전이를 유도합니다.
scene.onBeforeRenderObservable.add(() => {
const moving = input.w || input.a || input.s || input.d;
animator.setMoveIntent(moving);
// 실제 이동(물리/내비게이션)은 별도로 처리하세요.
// 핵심은 "움직이느냐/멈췄느냐"만 안정적으로 animator에 전달하는 것입니다.
});
왜 이 방식이 실무에서 안전한가
- 애니메이션 재생 호출이 “상태가 바뀔 때만” 일어나서, 불필요한 재시작/깜빡임이 줄어듭니다.
- 이동(로직)과 애니메이션(표현)을 상태로 분리하면, 버그가 나도 “어느 상태에서 문제가 났는지”가 바로 보입니다.
onAnimationEnd는 “끝나는 동작”의 연결 고리로 사용하고, 루프 동작(Idle/Run)은 입력/속도로 전환하는 식으로 역할을 나눌 수 있습니다. :contentReference[oaicite:4]{index=4}
실행 단계(실습 체크리스트)
| 단계 | 할 일 | 완료 기준 |
|---|---|---|
| 1 | 모델 로드 후 animationGroups 이름 출력 |
Idle/Run/Attack 등 정확한 이름 확인 |
| 2 | 상태 2개(Idle, Run)만 먼저 구현 |
이동 시작 시 Run, 멈추면 Idle로 즉시 복귀 |
| 3 | 상태 Action 추가 + Attack 1회 재생 |
Attack 종료 후 입력에 따라 Idle/Run으로 복귀 |
| 4 | 전환이 거칠면 블렌딩(선택) 적용 | Idle↔Run 전환이 부드럽게 보임 |
참고로 “상태머신”은 꼭 거대한 프레임워크가 아닙니다. 지금 구현한 것처럼 상태 값과 전이 규칙만 있어도 충분히 상태머신처럼 동작합니다. Babylon.js의 상태머신 안내(게임 제작 가이드)도 함께 참고하면 구조 감각을 잡는 데 도움이 됩니다. :contentReference[oaicite:5]{index=5}
추가로 생각해볼 점
- 상태 우선순위:
Action(공격/피격/점프)이Run보다 우선인지, 또는 이동으로 캔슬 가능한지 규칙을 먼저 정해두면 확장이 쉽습니다. - 전이 디바운스: 입력이 빠르게 떨릴 때(키 떼기/누르기 반복) 상태가 바뀌며 튀는 느낌이 나면, 최소 유지 시간(예: 0.1초)을 두는 방식도 유효합니다.
- 블렌딩 적용: 전환이 “뚝” 끊기면 애니메이션 블렌딩을 켜서 완화할 수 있습니다. Babylon.js의 고급 애니메이션 방법 문서도 함께 확인해 보세요. :contentReference[oaicite:6]{index=6}
- 확장 포인트:
Walk추가(속도에 따라 Walk/Run),Turn(회전),Jump(점프 시작/낙하/착지)처럼 상태를 늘릴 때도 동일한 패턴을 유지하는 것이 중요합니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
::contentReference[oaicite:7]{index=7}
0 댓글