미니게임 상태 머신 설계: 홈 화면부터 전투, 정산까지 이어지는 전체 흐름

"카드/보드 게임 버그의 80%는 부적절한 상태 설계에서 나오며, 상태 머신은 상태 판단이 여기저기 흩어지는 문제를 효과적으로 해결할 수 있습니다."
"중첩 상태 머신은 복잡한 게임의 주류 설계 패턴이 되었고, Battle State 내부에는 BeginBattle, HeroTurn, EnemyTurn, EndBattle 네 개의 하위 상태를 둘 수 있습니다."
만든 미니게임을 막 출시했는데, 플레이어가 정산 화면이 자주 멈춘다고 피드백합니다. 게임은 이미 끝났는데 화면은 여전히 전투 상태를 보여줍니다. 코드를 열어 보니 if (isPlaying && !isPaused && !isGameOver && hasPlayerLeft) 같은 조건이 화면 가득 흩어져 있습니다. 그 순간 드는 생각은 하나입니다. 이 코드는 누가 쓴 거지? 아, 저였습니다.
예전에 카드/보드 미니게임을 만들 때 저도 이 문제를 겪었습니다. 상태 판단이 boolean 변수로 여기저기 흩어져 있었고, 기능 하나를 바꾸려면 열몇 개 파일을 뒤져야 했습니다. 가장 힘들었던 건 멀티플레이 전투였습니다. 서버는 정산 상태로 넘어갔는데 클라이언트는 아직 전투 애니메이션을 재생하고 있었습니다. 양쪽 상태가 드리프트한 것입니다. 나중에 상태 머신으로 리팩터링하고 나서야 코드가 꽤 정돈됐습니다.
이 글에서는 미니게임 상태 머신을 어떻게 설계할지 이야기합니다. 홈 화면에서 전투, 정산까지 이어지는 흐름을 게임 흐름 계층, 전투 흐름 계층, 전투 세부 계층으로 나눠 설명하겠습니다. 그리고 상태 잠금, 서버 검증, 타임아웃 메커니즘처럼 실제 작업에서 정말 도움이 되는 함정 회피 경험도 함께 다룹니다.
왜 미니게임에 상태 머신이 필요할까요?
쉽게 말하면 게임은 여러 상태가 계속 오가는 시스템입니다.
홈, 전투, 정산, 일시정지, 관전. 각 화면 뒤에는 하나의 상태가 있습니다. 플레이어가 시작 버튼을 누르면 홈 화면이 사라지고 전투 화면이 나타납니다. 전투가 끝나면 정산 팝업이 뜹니다. 말로는 단순하지만 코드로 옮기면 금방 어지러워집니다.
if-else 폭포의 세 가지 문제
먼저 이런 코드를 본 적이 있을 겁니다.
// 상태 판단이 여기저기 흩어져 있음
if (isPlaying && !isPaused && !isGameOver) {
// 플레이어가 조작할 수 있음
}
if (isGameOver && !hasShownResult) {
// 정산 화면 표시
}
if (isPlaying && currentPlayer === 'player1') {
// player1의 턴
}
이 코드에는 세 가지 문제가 있습니다.
읽기 어렵습니다. 현재 상태를 판단하려면 파일 전체에서 boolean 변수를 찾아야 합니다. isPlaying, isPaused, isGameOver 같은 변수가 곳곳에 흩어져 있고, 퍼즐 조각을 맞추듯 직접 전체 그림을 복원해야 합니다.
수정하기 어렵습니다. 새로 일시정지 상태를 추가하려면 열몇 곳의 if 판단을 바꿔야 합니다. 한 곳만 빠뜨려도 버그가 생깁니다. 예전에 관전 상태를 추가하면서 이틀 동안 코드를 고쳤는데, 배포 후에도 문제가 났습니다. 어떤 애니메이션이 멈추지 않았던 것입니다.
디버깅하기 어렵습니다. 상태 전환 순서가 꼬이면 어디서 문제가 생겼는지 알기 힘듭니다. isGameOver는 true가 되었는데 hasShownResult는 여전히 false입니다. 대체 어떤 판단을 빠뜨린 걸까요?
Sohu의 한 글에서는 카드/보드 게임 버그의 80%가 부적절한 상태 설계에서 나온다고 말합니다. 숫자만 보면 과장처럼 보이지만, 겪어 본 사람은 압니다. 꽤 현실적인 이야기입니다.
상태 머신은 이 문제를 어떻게 해결할까요?
상태 머신은 흩어진 판단을 한곳으로 모읍니다.
각 상태에는 자기 행동이 있습니다. 들어올 때 무엇을 할지, 실행 중에는 무엇을 할지, 나갈 때는 무엇을 정리할지 정합니다. 상태 간 전환 규칙도 명확하게 씁니다. 홈에서는 전투로만 갈 수 있고, 정산으로 바로 뛰어넘을 수 없다는 식입니다.
비교해 보겠습니다.
// 상태 머신 방식
class HomeState {
enter() { showHomeUI(); }
exit() { hideHomeUI(); }
handleEvent(event) {
if (event === 'START_BATTLE') {
manager.changeState(new BattleState());
}
}
}
class BattleState {
enter() { showBattleUI(); startBattle(); }
exit() { hideBattleUI(); cleanupBattle(); }
}
상태가 캡슐화되었습니다. HomeState의 로직을 바꿔도 BattleState에는 영향을 주지 않습니다. 새 상태를 추가할 때도 새로운 State 클래스를 만들고 전환 규칙 하나를 더하면 됩니다.
Better Programming의 글에서도 중첩 상태 머신은 복잡한 게임의 주류 설계 패턴이 되었다고 말합니다. 미니게임도 예외는 아닙니다. 상태가 명확해야 코드도 명확해집니다.
3계층 상태 머신 아키텍처 자세히 보기
미니게임의 상태 머신은 계층을 나눠 설계해야 합니다. 계층을 나누지 않으면 전투 로직이 게임 흐름 계층 안으로 밀려 들어가고, 코드는 점점 비대해집니다.
저는 상태 머신을 세 계층으로 나눕니다. 게임 흐름 계층, 전투 흐름 계층, 전투 세부 계층입니다.
1계층: 게임 흐름 계층
게임이 시작되고 정산으로 끝나는 큰 뼈대입니다.
BootState: 게임 시작 시 리소스를 로드하고 엔진을 초기화합니다. Cocos Creator의 Boot 씬이 보통 이 역할을 합니다. 먼저 Boot를 실행한 다음 Home으로 전환합니다.
HomeState: 홈 메뉴입니다. 플레이어가 스테이지를 고르고, 캐릭터를 선택하고, 랭킹을 봅니다. 여기의 로직은 비교적 단순합니다. UI를 표시하고 버튼 클릭을 처리합니다.
BattleState: 전투 장면입니다. 이 계층은 끝점이 아니라 컨테이너입니다. 전투 내부에는 다시 하위 상태 머신이 있습니다.
SettlementState: 정산 화면입니다. 승패, 통계 데이터, 전투 리소스 정리를 담당합니다.
흐름은 다음과 같습니다. Boot -> Home -> Battle -> Settlement -> 다시 Home으로 돌아가기 또는 종료.
코드 대신 간단히 그려 보면 이렇습니다.
게임 시작 -> BootState
↓ (로드 완료)
HomeState
↓ (시작 클릭)
BattleState <- 전투 하위 상태 머신이 여기 있음
↓ (전투 종료)
SettlementState
↓ (돌아가기 클릭)
HomeState
2계층: 전투 흐름 계층
BattleState 내부에는 또 하나의 하위 상태 머신이 있습니다. Better Programming 글에서는 Battle State가 BeginBattle, HeroTurn, EnemyTurn, EndBattle 네 개의 하위 상태를 포함할 수 있다고 설명합니다.
BeginBattleState: 전투를 초기화합니다. 스테이지 파라미터를 설정하고, 캐릭터 데이터를 로드하고, 모든 플레이어의 상태를 동기화합니다. 멀티플레이 전투라면 이 단계에는 잠금을 걸어 중간 합류나 이탈을 막아야 합니다.
PlayerTurnState: 플레이어 턴입니다. 카드 내기, 공격, 방어 같은 조작을 이 상태에서 처리합니다. 턴이 끝나면 다음 턴이나 정산으로 전환합니다.
EnemyTurnState(선택): AI 행동입니다. 싱글플레이 미니게임에서는 건너뛸 수 있고, 멀티플레이에서는 상대 턴에 해당합니다.
EndBattleState: 정산 로직입니다. 승패를 판정하고, 보상을 계산하고, 정산 애니메이션을 트리거합니다.
흐름은 다음과 같습니다.
BattleState 진입 -> BeginBattleState (잠금, 동기화)
↓ (준비 완료)
PlayerTurnState
↓ (턴 종료)
EnemyTurnState (선택)
↓ (전투 종료)
EndBattleState
↓ (정산 완료)
BattleState 종료 -> SettlementState
3계층: 전투 세부 계층
일부 상태는 더 세분화해야 합니다. 예를 들어 PlayerTurnState 내부에는 다음 상태가 있을 수 있습니다.
애니메이션 상태 머신: 캐릭터가 공격할 때 공격 애니메이션을 재생하고, 피해를 받으면 피격 애니메이션을 재생합니다. Unity와 Cocos Creator 모두 내장 애니메이션 상태 머신을 제공하므로 여기서는 자세히 다루지 않겠습니다.
턴 상태 머신: 카드 내기 -> 공격 -> 방어 -> 턴 종료. 각 동작이 하나의 하위 상태가 됩니다.
Beast Card Clash라는 게임은 setup, scoring, results screen을 각각 독립 상태 클래스로 캡슐화했습니다. 이런 설계의 장점은 한 단계의 로직을 바꿔도 다른 단계에 영향을 주지 않는다는 것입니다.
3계층 아키텍처의 장점은 책임이 분명하다는 점입니다. 홈 로직을 바꿔도 전투 로직을 망가뜨리지 않습니다. 턴 세부 동작을 고쳐도 정산 흐름에는 영향을 주지 않습니다. 팀 협업도 쉬워집니다. 한 사람은 전투 흐름 계층을 맡고, 다른 사람은 세부 계층을 맡을 수 있습니다.
상태 머신 핵심 인터페이스 설계
3계층 아키텍처를 봤으니 이제 코드를 어떻게 쓸지 보겠습니다.
Zhihu의 한 Unity 글에서는 상태 머신 노드의 공통 인터페이스로 OnEnter, OnUpdate, OnExit, OnHandleEvent를 언급합니다. 이 설계를 참고해 TypeScript로 한 세트를 작성해 봤습니다.
IGameState 인터페이스
모든 상태는 이 인터페이스를 구현해야 합니다.
interface IGameState {
name: string; // 상태 이름. 디버깅할 때 식별하기 쉬움
enter(params?: any): void; // 상태에 들어올 때 초기화
update(dt: number): void; // 매 프레임 업데이트(선택)
exit(): void; // 상태에서 나갈 때 정리
handleEvent(event: GameEvent): void; // 이벤트를 처리하고 상태 전환을 트리거
}
다섯 메서드의 역할은 다음과 같습니다.
name: 디버깅용입니다. 로그를 출력할 때 현재 상태가 “HomeState”인지 “BattleState”인지 바로 보입니다. boolean 변수를 보는 것보다 직관적입니다.
enter: 상태에 들어올 때 호출합니다. HomeState의 enter는 홈 UI를 표시하고 플레이어 데이터를 로드합니다. BattleState의 enter는 전투 파라미터를 초기화하고 전투 하위 상태 머신에 진입합니다.
update: 매 프레임 업데이트입니다. 전투 장면에서 자주 씁니다. 카운트다운을 갱신하고 플레이어 조작을 감지합니다. 홈 화면에서는 거의 쓰지 않습니다.
exit: 상태에서 나갈 때 정리합니다. 리소스를 해제하고, UI를 숨기고, 리스너를 취소합니다.
handleEvent: 이벤트 기반 전환입니다. 시작 버튼을 누르면 START_BATTLE 이벤트가 발생하고, HomeState가 이 이벤트를 처리해 BattleState로 전환합니다.
GameStateManager 상태 매니저
상태 머신에는 상태를 전환하는 매니저가 필요합니다. Stack Exchange의 한 글에서는 CGameEngine 클래스 설계로 Init, Cleanup, ChangeState, PushState, PopState를 언급합니다.
저는 이를 조금 단순화했습니다.
class GameStateManager {
private currentState: IGameState | null = null;
private stateStack: IGameState[] = []; // 겹쳐지는 상태 지원(일시정지가 전투 위를 덮음)
// 초기화
init(firstState: IGameState) {
this.currentState = firstState;
this.currentState.enter();
}
// 상태 전환
changeState(newState: IGameState, params?: any) {
if (this.currentState) {
this.currentState.exit();
}
this.currentState = newState;
this.currentState.enter(params);
}
// 겹쳐지는 상태 push(일시정지, 팝업용)
pushState(state: IGameState) {
if (this.currentState) {
// 현재 상태는 종료하지 않고 잠시 멈춤
this.stateStack.push(this.currentState);
}
this.currentState = state;
state.enter();
}
// 겹쳐지는 상태 pop
popState() {
if (this.currentState) {
this.currentState.exit();
}
this.currentState = this.stateStack.pop();
// 이전 상태는 잠시 멈춘 상태였으므로 다시 enter할 필요가 없음
}
// 매 프레임 업데이트
update(dt: number) {
if (this.currentState) {
this.currentState.update(dt);
}
}
// 이벤트 처리
handleEvent(event: GameEvent) {
if (this.currentState) {
this.currentState.handleEvent(event);
}
}
}
changeState: 상태를 전환합니다. 기존 상태는 exit하고 새 상태는 enter합니다. 홈에서 전투로 넘어갈 때 이 메서드를 씁니다.
pushState / popState: 상태를 겹쳐 올립니다. 전투 중 일시정지 버튼을 누르면 PauseState를 스택 최상단에 올려 BattleState를 덮습니다. 일시정지를 해제하면 PauseState를 꺼내고 BattleState가 이어서 동작합니다.
겹쳐지는 상태는 중요합니다. 일시정지, 팝업, 확인창은 모두 현재 상태 위를 덮어야 하지만, 현재 상태를 파괴해서는 안 됩니다.
실전에서 밟기 쉬운 문제와 해결책
이론은 여기까지 하고, 이제 실제로 자주 밟는 문제를 보겠습니다. Sohu 글에서는 카드/보드 게임에서 겪는 함정을 정리했는데, 그중 대표적인 문제 몇 가지를 조금 더 풀어 보겠습니다.
상태 드리프트: 서버와 클라이언트 상태가 달라질 때
멀티플레이 전투에서 가장 쉽게 만나는 문제입니다.
상황은 이렇습니다. 서버는 정산 상태로 전환했지만, 어떤 플레이어의 클라이언트는 아직 전투 애니메이션을 재생하고 있습니다. 네트워크 지연 때문에 상태 전환 브로드캐스트를 받지 못한 것입니다. 화면에는 전투 중이라고 나오지만 실제로는 이미 끝났습니다.
원인: 상태 전환 메시지가 유실되었거나 너무 늦게 도착했습니다.
해결책:
-
상태 동기화 메커니즘: 서버가 상태를 전환할 때 모든 클라이언트에 브로드캐스트합니다. 클라이언트는 메시지를 받으면 즉시 동기화합니다.
-
하트비트 검사: 클라이언트가 몇 초마다 현재 상태를 담아 서버에 하트비트를 보냅니다. 서버가 상태 불일치를 발견하면 동기화 메시지를 강제로 푸시합니다.
// 클라이언트 하트비트
class BattleState {
enter() {
this.startHeartbeat();
}
startHeartbeat() {
setInterval(() => {
socket.send({
type: 'HEARTBEAT',
state: this.manager.currentState.name,
roomId: this.roomId
});
}, 3000); // 3초마다 한 번 전송
}
}
동시성 충돌: 여러 플레이어가 동시에 조작할 때
카드 테이블에서 두 플레이어가 동시에 카드를 내면 상태 전환 순서가 꼬일 수 있습니다.
원인: 상태 잠금이 없고, 동시 작업이 큐잉되지 않았습니다.
해결책: 상태 잠금 + 현재 조작자 ID 메커니즘을 사용합니다.
class DealingState implements IGameState {
private lock: boolean = true;
private currentOperator: string = '';
enter() {
this.lock = true; // 카드 분배 단계에서는 잠금
// 플레이어 입장과 퇴장 금지
// 모든 플레이어의 초기 데이터 동기화
setTimeout(() => this.unlock(), 3000); // 3초 뒤 잠금 해제
}
unlock() {
this.lock = false;
this.currentOperator = this.getFirstPlayerId();
this.manager.changeState(new PlayerTurnState());
}
handleEvent(event: GameEvent) {
if (this.lock) {
// 잠금 상태에서는 조작 거부
return;
}
// 현재 조작자만 이벤트를 트리거할 수 있음
if (event.playerId === this.currentOperator) {
// 조작 처리
}
}
}
잠금의 장점은 핵심 순간, 예를 들어 카드 분배나 정산 중에는 하나의 흐름만 실행되도록 보장한다는 점입니다. 플레이어 조작으로 중간에 끊기지 않습니다.
정산 안전성: 변조된 패키지가 가짜 점수를 올릴 때
클라이언트가 정산 데이터, 즉 점수와 승패를 업로드합니다. 변조된 패키지가 점수를 바꿔 올리면 서버는 어떻게 진짜인지 알 수 있을까요?
원인: 서버가 클라이언트 데이터를 신뢰했습니다.
해결책: 서버가 독립적으로 계산하고 클라이언트 결과를 신뢰하지 않습니다.
// 서버 정산 로직
class EndBattleState {
enter() {
// 클라이언트가 올린 점수를 받지 않음
// 서버가 전투 기록을 기준으로 독립 계산
const result = this.calculateResultFromLog(battleLog);
// 모든 클라이언트에 결과 전송
this.broadcastResult(result);
}
calculateResultFromLog(log: BattleLog) {
// 전투 기록(카드 사용 순서, 공격 데이터)을 기준으로 독립 계산
// 클라이언트 로그는 조작될 수 있으므로 핵심 데이터는 서버가 검증해야 함
}
}
핵심 원칙은 간단합니다. 서버는 클라이언트를 신뢰하지 않습니다. 클라이언트는 표시만 담당하고, 판정은 서버가 합니다.
타임아웃 프리징: 상태에 종료 조건이 없을 때
어떤 상태가 멈춰서 다음 상태로 영원히 넘어가지 않습니다.
원인: 상태에 타임아웃 메커니즘이 없고, 기다리던 이벤트가 발생하지 않았습니다.
해결책: 각 상태에 타이머를 설정합니다.
class PlayerTurnState implements IGameState {
private timeoutTimer: number;
enter() {
this.timeoutTimer = setTimeout(() => {
// 시간이 지나면 자동으로 건너뜀
this.manager.handleEvent({
type: 'TIMEOUT_SKIP',
playerId: this.currentPlayer
});
}, 30000); // 30초 타임아웃
}
exit() {
clearTimeout(this.timeoutTimer); // 나갈 때 타이머 정리
}
}
타임아웃 메커니즘은 중요합니다. 플레이어가 접속을 끊거나 네트워크가 멈춰도 상태가 계속 기다리기만 해서는 안 됩니다. 마지막 안전장치가 필요합니다.
네 가지 함정은 여기까지입니다. 이런 경험은 이론보다 중요합니다. 저도 상태 드리프트 때문에 이틀을 추적한 적이 있습니다. 나중에 하트비트 검사를 넣고 나서야 문제가 사라졌습니다.
Cocos Creator 실전 사례
이론, 인터페이스, 함정 회피 경험까지 봤습니다. 이제 Cocos Creator에서는 어떻게 적용할지 보겠습니다.
이전 글인 Cocos Creator 미니게임 프로젝트 구조에서는 Boot, 씬, 정산 페이지를 어떻게 나눌지 이야기했습니다. 이번 글은 그 생각을 이어 갑니다. 상태 머신을 Layer와 어떻게 결합할까요?
단일 씬 아키텍처에서의 상태 머신
Cocos Creator 미니게임에는 단일 씬 + 다중 Layer 구성이 잘 맞습니다. 하나의 씬 안에 여러 Layer를 두고 표시와 숨김을 전환합니다. 상태 머신은 이 구조와 정확히 맞물립니다.
BootLayer -> HomeLayer -> BattleLayer -> SettlementLayer
각 Layer는 하나의 상태에 대응됩니다.
import { director, Node } from 'cc';
// HomeState
class HomeState implements IGameState {
name = 'Home';
private homeLayer: Node | null = null;
enter() {
// HomeLayer 표시
const scene = director.getScene();
this.homeLayer = scene?.getChildByName('HomeLayer') ?? null;
if (this.homeLayer) {
this.homeLayer.active = true;
this.initHomeUI();
}
}
exit() {
// HomeLayer 숨김
if (this.homeLayer) {
this.homeLayer.active = false;
this.cleanupHome();
}
}
handleEvent(event: GameEvent) {
if (event.type === 'START_BATTLE') {
// BattleState로 전환
this.manager.changeState(new BattleState(), event.params);
}
}
initHomeUI() {
// 플레이어 데이터 로드, 버튼 클릭 이벤트 설정
}
cleanupHome() {
// 홈 리소스 해제
}
}
// BattleState
class BattleState implements IGameState {
name = 'Battle';
private battleLayer: Node | null = null;
enter(params?: any) {
const scene = director.getScene();
this.battleLayer = scene?.getChildByName('BattleLayer') ?? null;
if (this.battleLayer) {
this.battleLayer.active = true;
this.startBattle(params);
}
}
exit() {
if (this.battleLayer) {
this.battleLayer.active = false;
this.cleanupBattle();
}
}
startBattle(params: any) {
// 전투 하위 상태 머신에 진입
this.battleStateManager.init(new BeginBattleState(params));
}
cleanupBattle() {
// 전투 리소스 정리
}
}
Layer의 표시와 숨김은 상태 전환의 시각적 표현입니다. enter에서 표시하고 exit에서 숨깁니다.
상태 머신과 데이터 전달
상태를 전환할 때 파라미터를 전달합니다. 예를 들어 홈에서 스테이지를 선택했다면 BattleState는 어떤 스테이지인지 알아야 합니다.
// HomeState가 전환 시작
handleEvent(event: GameEvent) {
if (event.type === 'START_BATTLE') {
// 스테이지 파라미터 전달
this.manager.changeState(new BattleState(), {
levelId: event.levelId,
difficulty: event.difficulty
});
}
}
// BattleState가 파라미터 수신
enter(params?: any) {
const levelId = params?.levelId ?? 'default';
const difficulty = params?.difficulty ?? 'normal';
this.loadLevel(levelId, difficulty);
}
상태 영속화에는 localStorage를 사용할 수 있습니다. 전투 도중 나갔다가 다음에 다시 들어오면 진행 상황을 복구합니다.
// 진행 상황 저장
exit() {
localStorage.setItem('battle_progress', JSON.stringify({
levelId: this.levelId,
round: this.currentRound,
score: this.score
}));
}
// 진행 상황 복구
enter() {
const saved = localStorage.getItem('battle_progress');
if (saved) {
const progress = JSON.parse(saved);
this.resumeFromProgress(progress);
}
}
WeChat 미니게임에서 주의할 점
CSDN 글에서는 WeChat 미니게임의 상태 머신을 전역 파일에 두면 수정하기 쉽다고 말합니다.
패키지 크기 제한: WeChat 미니게임의 메인 패키지는 4MB입니다. 상태 머신 코드는 간결해야 하고, 클래스를 지나치게 많이 만들면 안 됩니다.
서브패키지 로딩: 핵심 상태 머신은 메인 패키지에 둡니다. Boot, Home, Battle 같은 상태입니다. 확장 상태는 서브패키지에 둘 수 있습니다. 특수 스테이지나 이벤트 화면이 여기에 해당합니다.
전역 상태 파일: WeChat 미니게임의 엔트리 파일 game.js에서 상태 머신을 초기화하고, 다른 모듈은 전역 변수로 접근합니다.
// game.js
import { GameStateManager } from './states/GameStateManager';
import { BootState } from './states/BootState';
// 전역 상태 매니저
window.gameStateManager = new GameStateManager();
window.gameStateManager.init(new BootState());
WeChat API를 호출할 때는 전역 변수로 상태를 가져옵니다.
// WeChat 로그인 콜백
wx.login({
success: (res) => {
const state = window.gameStateManager.currentState;
if (state.name === 'HomeState') {
state.handleEvent({ type: 'LOGIN_SUCCESS', code: res.code });
}
}
});
이 구조의 장점은 WeChat API 쪽에서 상태 머신을 import하지 않아도 된다는 점입니다. 전역 변수로 접근하면 됩니다.
이 글은 이전 글 Cocos Creator 미니게임 프로젝트 구조와 함께 읽으면 더 잘 이어집니다. 프로젝트 구조는 상태 머신의 컨테이너이고, 상태 머신은 프로젝트 구조의 행동 로직입니다.
정리
길게 이야기했지만 상태 머신은 결국 게임 흐름을 명확한 노드로 나누는 방법입니다.
3계층 아키텍처, 즉 게임 흐름 계층, 전투 흐름 계층, 전투 세부 계층은 미니게임부터 큰 프로젝트까지 모두 사용할 수 있습니다. 한 계층을 바꿔도 다른 계층에 영향을 주지 않고, 팀 협업도 쉬워집니다.
함정 회피 경험은 이론보다 중요합니다. 상태 드리프트, 동시성 충돌, 정산 안전성, 타임아웃 프리징. 이 네 가지 문제는 직접 겪어 보면 추적이 꽤 고통스럽습니다. 이후 상태 잠금, 하트비트 검사, 서버 검증, 타임아웃 메커니즘을 넣고 나서 문제가 사라졌습니다. 코드를 제대로 구조화하면 버그도 훨씬 줄어듭니다.
직접 해 보고 싶다면 전체 코드 예제(GitHub 링크)를 내려받아 볼 수 있습니다. 먼저 이전 글 Cocos Creator 미니게임 프로젝트 구조를 읽고 Layer를 어떻게 나누는지 이해한 뒤, 이 글에서 상태 머신이 어떻게 동작하는지 보면 됩니다. 두 글을 함께 보면 아키텍처가 훨씬 선명해집니다.
다음 단계에서는 AI를 활용한 상태 머신 테스트를 이야기해 보겠습니다. AI가 다양한 상태 전환 경로를 덮는 테스트 케이스를 생성하도록 만들면, 자동화된 테스트 덕분에 함정을 밟을 확률도 낮아집니다.
미니게임 3계층 상태 머신 아키텍처 설계하기
홈 화면에서 전투를 거쳐 정산까지 이어지는 전체 상태 관리 구현 흐름
⏱️ Estimated time: 60 min
- 1
Step 1: 게임 흐름 계층 상태 정의
게임의 주요 흐름 상태를 나열합니다. BootState, HomeState, BattleState, SettlementState를 정의하고 각 상태가 하나의 Layer나 씬에 대응되는지 정한 뒤, 상태 간 전환 규칙을 확정합니다. - 2
Step 2: 전투 흐름 계층의 하위 상태 분리
BattleState 내부에 하위 상태 머신을 만듭니다. BeginBattleState(초기화와 잠금), PlayerTurnState(플레이어 턴), EnemyTurnState(선택적 AI 턴), EndBattleState(정산 판정)로 나눕니다. - 3
Step 3: 상태 인터페이스와 매니저 구현
IGameState 인터페이스(name, enter, exit, update, handleEvent)를 만들고, GameStateManager로 상태 전환(changeState)과 스택 조작(pushState, popState)을 관리합니다. - 4
Step 4: 함정 방지 메커니즘 추가
카드 분배나 정산 같은 핵심 상태에는 잠금을 걸어 동시 작업 충돌을 막고, 타이머로 프리징을 피하며, 하트비트 검사로 상태 드리프트를 해결하고, 서버 독립 계산으로 정산 조작을 막습니다. - 5
Step 5: Cocos Creator Layer와 연결
enter에서 해당 Layer를 표시하고 데이터를 초기화하며, exit에서 Layer를 숨기고 리소스를 정리합니다. 상태 머신이 UI 전환을 구동하도록 만들어 코드 로직과 시각 표현을 분리합니다.
FAQ
미니게임에도 꼭 상태 머신이 필요할까요?
3계층 상태 머신은 과한 설계 아닌가요?
상태 머신과 씬 전환은 무엇이 다른가요?
멀티플레이 전투에서 상태 드리프트는 어떻게 해결하나요?
정산 데이터 조작은 어떻게 막나요?
4분 읽기 · 게시일: 2026년 5월 19일 · 수정일: 2026년 10월 21일
AI 기반 Cocos 미니게임 개발
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Cocos Creator 미니게임 프로젝트 구조: Boot, 씬, 결과 화면 나누는 법
Cocos Creator 미니게임 프로젝트 구조의 모범 사례를 정리합니다. 단일 씬 아키텍처와 4개 Layer 전환, Boot 시작 화면, 메인 씬, 결과 화면 분리 방식과 코드 예시를 함께 살펴봅니다.
21편 중 3편
다음
AI로 미니게임 아이디어를 PRD와 작업 목록으로 쪼개는 법
AI를 활용해 30분 안에 미니게임 아이디어를 완성도 있는 PRD와 개발 작업 목록으로 분해하는 방법을 소개합니다. Prompt 템플릿, 미니게임 전용 PRD 구조, 실전 사례를 포함해 인디 개발자와 소규모 팀에 적합합니다.
21편 중 5편



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