Command 패턴으로 만드는 인게임 입력 버퍼 시스템

격투 게임이나 액션 게임에서 입력이 조금 빨라도 기술이 자연스럽게 이어지도록 하는 Command 기반 입력 버퍼 시스템을 설계합니다. Unity와 Unreal에서도 옮겨 쓸 수 있는 C++ 중심 구조와 구현 시 주의점을 함께 정리합니다.

✓ 복사 완료!
챕터시스템 아키텍처 & 패턴
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Command 패턴으로 만드는 인게임 입력 버퍼 시스템

입력 버퍼가 필요한 이유

플레이어는 애니메이션이 끝나는 정확한 프레임에 맞춰 다음 버튼을 누르지 않습니다. 공격이 끝나기 직전에 다음 공격 버튼을 누르거나 회피가 끝나기 전에 이동 입력을 넣는 일이 더 자연스럽습니다. 이때 게임이 입력을 즉시 버리면 조작감이 둔하고 답답하게 느껴집니다.

입력 버퍼는 지금 당장 실행할 수 없는 입력을 잠시 보관했다가 캐릭터가 실행 가능한 상태가 되었을 때 소비하는 장치입니다. 단순히 버튼 상태를 읽는 방식과 달리, 입력을 실행 의도로 변환해 다룬다는 점이 중요합니다.

입력 이벤트가 명령으로 변환되어 버퍼를 거쳐 캐릭터 행동으로 실행되는 과정

Command 패턴으로 입력과 행동 분리하기

Command 패턴은 요청을 객체로 감싸는 패턴입니다. 입력 장치는 키보드와 패드, AI, 네트워크 재생처럼 계속 바뀔 수 있지만 캐릭터가 수행하는 행동은 Attack, Dodge, Jump처럼 비교적 안정적입니다.

따라서 입력을 받는 쪽은 “어떤 키를 눌렀는가”만 판단하고 게임플레이 쪽에는 “어떤 행동을 요청하는가”를 전달하게 만듭니다.

class Character;

class ICommand
{
public:
    virtual ~ICommand() = default;
    virtual bool CanExecute(const Character& character) const = 0;
    virtual void Execute(Character& character) const = 0;
    virtual const char* GetName() const = 0;
};

class AttackCommand final : public ICommand
{
public:
    bool CanExecute(const Character& character) const override;
    void Execute(Character& character) const override;
    const char* GetName() const override { return "Attack"; }
};

class DodgeCommand final : public ICommand
{
public:
    bool CanExecute(const Character& character) const override;
    void Execute(Character& character) const override;
    const char* GetName() const override { return "Dodge"; }
};

CanExecute는 명령이 유효한지 확인하고 Execute는 실제 행동을 시작합니다. 예를 들어 공격 중에는 회피가 가능하지만 피격 경직 중에는 둘 다 불가능할 수 있습니다. 이 규칙을 입력 처리 코드에 섞지 않고 명령 또는 캐릭터 상태에 둬야 유지보수가 쉬워집니다.

버퍼가 다루어야 할 정보

명령 그 자체만 큐에 넣으면 만료 시간을 처리하기 어렵습니다. 입력 시점과 유효 기간, 필요하다면 입력 강도나 방향도 함께 기록합니다.

#include <memory>

struct BufferedCommand
{
    std::unique_ptr<ICommand> command;
    float submittedAt = 0.0f;
    float expiresAt = 0.0f;
};

버퍼 시간은 게임 장르에 따라 다릅니다. 빠른 액션 게임이라면 0.1초에서 0.25초 정도로 짧게 시작해 플레이 테스트로 조정하는 편이 안전합니다. 너무 길면 플레이어가 이미 의도를 바꾼 입력까지 나중에 실행되어 조작이 무겁게 느껴집니다.

입력 처리와 실행은 다음처럼 분리됩니다.

flowchart LR
    A[입력 이벤트] --> B[Input Mapper]
    B --> C[Command 생성]
    C --> D[입력 버퍼]
    D --> E{현재 실행 가능?}
    E -- 예 --> F[Command 실행]
    E -- 아니오 --> G[만료 전까지 유지]
    G --> E
    G -- 만료 --> H[명령 폐기]

이 구조에서는 키보드의 스페이스 바, 게임패드의 A 버튼, AI의 점프 판단이 모두 같은 JumpCommand를 생성할 수 있습니다. 입력 소스가 바뀌어도 버퍼와 캐릭터 행동 규칙은 그대로 재사용됩니다.

FIFO 큐만으로는 부족한 경우

가장 단순한 입력 버퍼는 선입선출 큐입니다. 그러나 게임에서는 앞선 명령이 현재 상태에서 실행 불가능하다고 해서 뒤의 명령까지 막아서는 안 되는 경우가 있습니다.

예를 들어 공격 입력 뒤에 회피 입력이 들어왔을 때 공격이 불가능하지만 회피는 가능하다면 회피를 실행하는 편이 자연스럽습니다. 반대로 격투 게임처럼 입력 순서가 콤보 판정의 일부라면 순서를 엄격히 보존해야 합니다.

먼저 게임의 규칙을 정해야 합니다.

  • 순서 우선: 가장 오래된 명령만 확인한다. 콤보와 예약 행동에 적합하다.
  • 실행 가능 우선: 버퍼 안에서 실행 가능한 명령을 찾는다. 일반적인 액션 조작에 적합하다.
  • 우선순위 우선: 회피나 방어처럼 중요한 명령에 높은 우선순위를 준다.
  • 최신 입력 우선: 같은 종류의 이동 또는 조준 입력은 새 입력으로 덮어쓴다.

모든 명령에 하나의 정책을 적용할 필요는 없습니다. 공격은 순서를 유지하고 이동은 최신 방향만 보존하는 식으로 명령 종류별 정책을 나누는 방식이 실용적입니다.

실행 가능 우선 버퍼 구현

아래 예제는 만료된 명령을 먼저 제거한 뒤 현재 실행 가능한 첫 명령을 실행합니다. std::deque는 앞뒤 삽입과 중간 순회가 필요한 작은 버퍼에 적합합니다.

#include <algorithm>
#include <deque>
#include <memory>

class CommandBuffer
{
public:
    explicit CommandBuffer(float lifetimeSeconds)
        : lifetimeSeconds_(lifetimeSeconds)
    {
    }

    void Push(std::unique_ptr<ICommand> command, float now)
    {
        if (!command)
        {
            return;
        }

        commands_.push_back({
            std::move(command),
            now,
            now + lifetimeSeconds_
        });
    }

    void Update(Character& character, float now)
    {
        RemoveExpired(now);

        auto it = std::find_if(
            commands_.begin(),
            commands_.end(),
            [&character](const BufferedCommand& buffered)
            {
                return buffered.command->CanExecute(character);
            });

        if (it == commands_.end())
        {
            return;
        }

        it->command->Execute(character);
        commands_.erase(it);
    }

    void Clear()
    {
        commands_.clear();
    }

private:
    void RemoveExpired(float now)
    {
        std::erase_if(commands_, [now](const BufferedCommand& buffered)
        {
            return now >= buffered.expiresAt;
        });
    }

    float lifetimeSeconds_;
    std::deque<BufferedCommand> commands_;
};

게임 루프에서는 입력 이벤트가 발생할 때 Push를 호출하고 캐릭터 상태를 갱신한 뒤 Update를 호출합니다. 상태 갱신보다 먼저 버퍼를 소비하면 방금 끝난 애니메이션이나 경직 상태를 한 프레임 늦게 인식할 수 있으므로 호출 순서를 일관되게 유지해야 합니다.

void PlayerController::Tick(float deltaTime)
{
    character_.UpdateState(deltaTime);
    commandBuffer_.Update(character_, gameTime_);
}

void PlayerController::OnAttackPressed()
{
    commandBuffer_.Push(std::make_unique<AttackCommand>(), gameTime_);
}

중복 입력과 취소 규칙

버퍼를 그대로 쌓기만 하면 공격 버튼을 연타했을 때 오래된 공격 명령이 여러 개 남을 수 있습니다. 이것이 의도한 연속 공격일 수도 있지만 회피 버튼을 여러 번 눌렀을 때 캐릭터가 연속 회피하는 원인이 되기도 합니다.

명령마다 중복 정책을 정의하면 문제를 줄일 수 있습니다. 예를 들어 DodgeCommand는 이미 버퍼에 있으면 새 입력으로 갱신하고 AttackCommand는 최대 두 개까지만 허용할 수 있습니다.

void CommandBuffer::PushDodge(float now)
{
    auto existing = std::find_if(
        commands_.begin(),
        commands_.end(),
        [](const BufferedCommand& buffered)
        {
            return std::string_view(buffered.command->GetName()) == "Dodge";
        });

    if (existing != commands_.end())
    {
        existing->submittedAt = now;
        existing->expiresAt = now + lifetimeSeconds_;
        return;
    }

    Push(std::make_unique<DodgeCommand>(), now);
}

실제 프로젝트에서는 문자열 비교 대신 명령 종류를 나타내는 열거형을 두는 편이 낫습니다. 문자열은 디버그 표시에 유용하지만 게임 규칙의 분기 조건으로 쓰면 오타와 리팩터링에 취약합니다.

enum class CommandType
{
    Attack,
    Dodge,
    Jump
};

상태 전환과 버퍼 소비 시점

입력 버퍼의 핵심은 “버퍼에 넣는 시점”보다 “소비하는 시점”입니다. 캐릭터의 상태 머신이 공격 종료 구간이나 캔슬 가능 구간에 진입할 때 버퍼를 확인하면 애니메이션 전환과 명령 실행을 자연스럽게 맞출 수 있습니다.

예를 들어 공격 상태는 다음 조건에서만 다음 명령을 소비하도록 만들 수 있습니다.

  • 공격 애니메이션의 캔슬 가능 구간에 진입했다.
  • 현재 공격이 적중했고 후속 콤보가 허용된다.
  • 캐릭터가 피격, 사망, 컷신 상태가 아니다.
  • 스태미나나 탄약 같은 행동 비용을 지불할 수 있다.

이 규칙을 CanExecute에 전부 몰아넣기보다 상태 머신이 “지금 버퍼 소비를 허용하는가”를 결정하고 명령은 행동 자체의 조건을 확인하도록 나누면 책임이 선명해집니다.

void AttackState::OnCancelWindowOpened(Character& character)
{
    if (character.IsCommandConsumptionAllowed())
    {
        character.TryConsumeBufferedCommand();
    }
}

디버깅과 테스트 체크리스트

입력 버퍼는 체감으로만 확인하면 원인을 찾기 어렵습니다. 개발 빌드에서는 버퍼에 들어간 시간, 만료 시간, 명령 종류, 실행 또는 폐기 이유를 화면이나 로그로 확인할 수 있게 두는 것이 좋습니다.

다음 항목을 자동 테스트 또는 수동 테스트 목록에 포함해 보세요.

  • 행동이 끝나기 직전 입력이 다음 행동으로 이어지는지 확인한다.
  • 만료된 입력이 늦게 실행되지 않는지 확인한다.
  • 피격과 사망, 메뉴 진입 시 버퍼가 의도대로 비워지는지 확인한다.
  • 프레임이 낮아져도 시간 기준 만료가 일관되게 동작하는지 확인한다.
  • 같은 버튼 연타가 의도하지 않은 행동 예약을 만들지 않는지 확인한다.
  • 입력 소스가 키보드, 패드, AI로 바뀌어도 같은 명령 경로를 거치는지 확인한다.

마무리

Command 패턴으로 입력을 행동 요청으로 바꾸면 입력 장치와 게임플레이 규칙을 분리할 수 있습니다. 여기에 시간 제한과 중복 정책, 상태별 소비 규칙을 더하면 단순한 큐가 실제 게임에 필요한 입력 버퍼 시스템이 됩니다.

처음에는 명령 종류 몇 개와 짧은 유효 시간으로 시작하세요. 이후 플레이 테스트에서 누락되는 입력과 원치 않는 예약 행동을 구분해 버퍼 시간, 우선순위, 중복 규칙을 조정하면 복잡도를 과도하게 늘리지 않고도 조작감을 개선할 수 있습니다.