
Unity C# Command 패턴으로 격투 게임 선입력(Input Buffer) 시스템 구현하기
격투·액션 게임에서 입력이 씹히는 문제를 Command 패턴 기반 Input Buffer로 해결합니다. Unity C# 예제와 우선순위, 유효 시간, 상태 전환 처리까지 단계별로 구현합니다.
TL;DR: 선입력 시스템은 왜 필요한가?
격투·액션 게임의 선입력(Input Buffer)은 공격 경직, 착지, 애니메이션 전환 중 들어온 입력을 짧은 시간 보관했다가 캐릭터가 행동 가능한 첫 프레임에 실행하는 시스템이다. Unity C#에서는 원시 키 입력을 즉시 실행하지 않고 ICommand 객체로 변환해 큐에 저장하면 입력 유효 시간·커맨드 우선순위·상태 조건을 분리해 안정적으로 관리할 수 있다.
격투 게임에서 입력이 씹히는 이유는 무엇인가?
플레이어는 보통 현재 공격의 마지막 프레임보다 조금 먼저 다음 공격 버튼을 누른다. 그러나 캐릭터가 아직 Attack 상태라면 즉시 실행 방식은 입력을 거부한다. 이때 입력 이벤트를 버리면 플레이어는 버튼을 눌렀는데도 반응하지 않는다고 느낀다.
선입력 시스템은 다음 두 시간을 연결한다.
- 입력 시점: 플레이어가 버튼 또는 방향을 누른 시점
- 실행 가능 시점: 캐릭터 상태 머신이 해당 행동을 허용하는 시점
버퍼 유효 시간을 bufferWindow이라고 하고 입력 시각을 tInput, 현재 시각을 tNow라고 하면 커맨드는 아래 조건에서만 살아 있다.
bufferWindow은 게임의 입력 감각에 직접 영향을 준다. 일반적인 실시간 액션에서는 0.10~0.20초부터 시작해 실제 프레임 데이터와 플레이 테스트로 조정하는 편이 좋다. 값이 지나치게 크면 오래전 입력이 늦게 발동해 조작이 둔하게 느껴진다.

Command 패턴 기반 Input Buffer 구조는 어떻게 설계할까?
Command 패턴은 요청을 객체로 캡슐화하는 패턴이다. 입력 버퍼에서는 LightAttackCommand, HeavyAttackCommand, JumpCommand처럼 행동 요청을 커맨드 객체로 표현한다. 입력 장치는 어떤 행동을 눌렀는지만 알면 되고 실제 실행 가능 여부는 캐릭터와 상태 머신이 판단한다.
| 구성 요소 | 책임 | Unity 구현 예시 |
|---|---|---|
| Input Reader | 물리 입력을 게임 행동으로 변환 | New Input System 콜백, Update() |
| Command | 실행 요청과 우선순위 보관 | ICommand, AttackCommand |
| Input Buffer | 커맨드 저장·만료·선택 | List<BufferedCommand> |
| Character Controller | 행동 가능 여부와 실제 실행 | CanExecute(), Execute() |
| State Machine | 현재 상태와 전환 규칙 관리 | Idle, Attack, HitStun |
다음 흐름처럼 입력 생성과 실행을 분리한다.
flowchart LR
A[플레이어 입력] --> B[Command 생성]
B --> C[Input Buffer에 저장]
C --> D{현재 상태에서 실행 가능?}
D -- 아니오 --> E[유효 시간까지 보관]
E --> D
D -- 예 --> F[우선순위 선택]
F --> G[Character Controller 실행]
C --> H[유효 시간 초과]
H --> I[커맨드 폐기]
Step 1. 실행 가능한 Command 인터페이스를 만든다
커맨드가 실행 조건과 실행 동작을 함께 제공하도록 인터페이스를 정의한다. CanExecute를 별도로 둬야 버퍼가 입력을 저장한 뒤 매 프레임 또는 상태 전환 시점에 재검사할 수 있다.
public interface ICommand
{
int Priority { get; }
bool CanExecute(FighterController fighter);
void Execute(FighterController fighter);
}
가벼운 공격 커맨드는 다음처럼 작성할 수 있다. 여기서 Priority는 같은 프레임에 여러 입력이 후보가 되었을 때 선택 기준으로 사용한다.
public sealed class LightAttackCommand : ICommand
{
public int Priority => 10;
public bool CanExecute(FighterController fighter)
{
return fighter.CanStartAttack(AttackType.Light);
}
public void Execute(FighterController fighter)
{
fighter.StartAttack(AttackType.Light);
}
}
public sealed class JumpCommand : ICommand
{
public int Priority => 20;
public bool CanExecute(FighterController fighter)
{
return fighter.IsGrounded && fighter.CanAcceptMovementInput;
}
public void Execute(FighterController fighter)
{
fighter.Jump();
}
}
CanExecute에는 상태 확인만 넣는 것이 좋다. 애니메이션 재생, 히트박스 활성화, 이동 속도 변경처럼 게임 상태를 바꾸는 작업은 Execute에서만 수행해야 한다.
Step 2. 유효 시간과 우선순위를 가진 Input Buffer를 구현한다
각 버퍼 항목은 커맨드와 입력 시간을 가진다. Time.time은 timeScale의 영향을 받으므로 일시 정지 중에도 UI 입력을 별도로 다뤄야 한다면 Time.unscaledTime 사용 여부를 정책으로 정해야 한다. 일반적인 전투 입력에는 Time.time이 자연스럽다.
using System;
using System.Collections.Generic;
using UnityEngine;
public sealed class InputBuffer
{
private readonly List<BufferedCommand> commands = new();
private readonly float bufferWindow;
public InputBuffer(float bufferWindow)
{
this.bufferWindow = bufferWindow;
}
public void Enqueue(ICommand command)
{
commands.Add(new BufferedCommand(command, Time.time));
}
public void Tick(FighterController fighter)
{
RemoveExpiredCommands();
BufferedCommand? candidate = FindBestExecutableCommand(fighter);
if (!candidate.HasValue)
{
return;
}
candidate.Value.Command.Execute(fighter);
commands.Remove(candidate.Value);
}
private void RemoveExpiredCommands()
{
commands.RemoveAll(item => Time.time - item.InputTime > bufferWindow);
}
private BufferedCommand? FindBestExecutableCommand(FighterController fighter)
{
BufferedCommand? best = null;
foreach (BufferedCommand item in commands)
{
if (!item.Command.CanExecute(fighter))
{
continue;
}
if (!best.HasValue || item.Command.Priority > best.Value.Command.Priority)
{
best = item;
}
}
return best;
}
private readonly struct BufferedCommand : IEquatable<BufferedCommand>
{
public readonly ICommand Command;
public readonly float InputTime;
public BufferedCommand(ICommand command, float inputTime)
{
Command = command;
InputTime = inputTime;
}
public bool Equals(BufferedCommand other)
{
return ReferenceEquals(Command, other.Command) && Mathf.Approximately(InputTime, other.InputTime);
}
}
}
위 구현은 한 번의 Tick()에서 하나의 커맨드만 실행한다. 한 프레임에 공격과 점프가 연쇄 실행되는 일을 막고 상태 머신이 새 상태를 반영할 기회를 주기 때문이다.
Step 3. Unity 입력과 캐릭터 상태 머신을 연결한다
입력이 발생한 순간에는 실행하지 않고 버퍼에 넣는다. Unity New Input System을 사용한다면 InputAction.performed 콜백에서 Enqueue()를 호출하면 된다.
using UnityEngine;
using UnityEngine.InputSystem;
public sealed class FighterInput : MonoBehaviour
{
[SerializeField] private FighterController fighter;
[SerializeField] private float bufferWindow = 0.15f;
private InputBuffer inputBuffer;
private void Awake()
{
inputBuffer = new InputBuffer(bufferWindow);
}
private void Update()
{
inputBuffer.Tick(fighter);
}
public void OnLightAttack(InputAction.CallbackContext context)
{
if (context.performed)
{
inputBuffer.Enqueue(new LightAttackCommand());
}
}
public void OnJump(InputAction.CallbackContext context)
{
if (context.performed)
{
inputBuffer.Enqueue(new JumpCommand());
}
}
}
입력 버퍼를 Update()에서 처리할지 상태 전환 직후 처리할지는 게임 규칙에 따라 달라진다.
| 처리 시점 | 장점 | 주의점 |
|---|---|---|
Update() 매 프레임 | 구현이 단순하고 반응이 빠름 | 상태 변경 순서에 따라 한 프레임 차이가 날 수 있음 |
| 상태 종료 이벤트 직후 | 콤보 연결 규칙을 명확히 제어 | 이벤트 누락 시 입력이 실행되지 않을 수 있음 |
| 둘 다 사용 | 즉시 행동과 콤보 전환을 모두 지원 | 같은 커맨드의 중복 실행을 막아야 함 |
실무에서는 Update()에서 만료와 실행 후보를 확인하고 공격 애니메이션의 캔슬 가능 프레임은 CanStartAttack() 내부에서 검사하는 방식이 유지보수하기 편하다.
입력 우선순위와 중복 입력은 어떻게 처리해야 할까?
우선순위만으로 모든 규칙을 해결하기보다 게임의 의도를 먼저 명시해야 한다. 예를 들어 점프가 일반 공격보다 우선인지 같은 공격 버튼을 연타하면 콤보 예약인지 대시 입력이 공격을 취소하는지에 따라 정책이 달라진다.
추천하는 기본 정책
- 만료 우선: 유효 시간이 지난 입력은 실행 가능 여부와 관계없이 먼저 제거한다.
- 실행 가능 커맨드만 비교: 현재 상태에서 불가능한 높은 우선순위 입력이 낮은 우선순위 입력을 막지 않게 한다.
- 동일 우선순위는 최신 입력 우선 또는 FIFO 중 하나로 고정: 액션 게임은 최신 방향 입력을 우선하는 경우가 많고 리듬감 있는 콤보는 FIFO가 더 자연스러울 수 있다.
- 실행 후 충돌 커맨드 제거: 강공격이 실행되면 이전의 약공격 예약을 제거하는 식으로 의도하지 않은 연쇄를 방지한다.
동일 행동을 여러 번 누르는 입력을 모두 보관하면 버퍼가 불필요하게 커질 수 있다. 단발 행동이라면 동일 타입 커맨드가 이미 있을 때 새 입력으로 갱신하는 방법이 유용하다.
public void EnqueueOrRefresh<TCommand>(TCommand command) where TCommand : ICommand
{
for (int i = commands.Count - 1; i >= 0; i--)
{
if (commands[i].Command.GetType() == typeof(TCommand))
{
commands.RemoveAt(i);
break;
}
}
commands.Add(new BufferedCommand(command, Time.time));
}
다만 3타 콤보처럼 같은 버튼의 연속 입력 자체가 의미를 가지는 게임에서는 중복 제거를 적용하면 안 된다. 이 경우에는 최대 버퍼 개수 또는 같은 종류의 최대 예약 횟수를 별도로 제한한다.
방향 커맨드와 모션 입력까지 확장하려면?
격투 게임의 236 + Punch 같은 모션 입력은 버튼 커맨드보다 방향 이력 관리가 중요하다. 방향 입력을 샘플 단위로 기록하고 최근 시간 구간에서 방향 시퀀스를 역순으로 검사한다.
방향 이력의 각 요소를 (direction, timestamp)로 두고 마지막 방향 입력과 첫 방향 입력의 차이가 motionWindow 이하여야 한다.
구현 시 다음을 분리하면 좋다.
DirectionHistory: 8방향 또는 9방향(중립 포함) 입력과 시간을 기록한다.MotionRecognizer:236,214, 차지 입력 등의 시퀀스를 판정한다.CommandFactory: 모션 판정 결과와 공격 버튼을 조합해HadokenCommand같은 고수준 커맨드를 생성한다.InputBuffer: 생성된 고수준 커맨드의 실행 시점만 관리한다.
이렇게 하면 모션 인식 규칙이 버퍼의 만료·우선순위 코드와 섞이지 않는다.
성능과 디버깅에서 확인할 점
입력 버퍼는 대체로 항목 수가 매우 작아서 단순한 List<T>로 충분하다. 다만 매 입력마다 new로 커맨드 클래스를 생성하면 장시간 플레이에서 GC 할당이 누적될 수 있다. 프로파일러에서 할당이 문제로 확인된 뒤에는 다음 중 하나를 선택한다.
- 행동 종류가 고정돼 있다면
enum CommandType과 값 타입 데이터를 사용한다. - 커맨드 객체가 상태를 갖지 않는다면 재사용 가능한 인스턴스를 둔다.
- 다수 캐릭터 또는 네트워크 리플레이를 처리한다면 링 버퍼로 최대 용량을 고정한다.
디버깅할 때는 버퍼에 저장된 커맨드 이름, 입력 시간, 남은 유효 시간, CanExecute 실패 이유를 화면이나 로그에 표시하면 문제가 빨리 드러난다. 특히 Attack 상태가 끝났는데도 입력이 실행되지 않는다면 상태 전환 순서와 CanStartAttack() 조건을 먼저 확인해야 한다.
자주 묻는 질문 (FAQ)
Input Buffer 시간은 몇 초가 적절한가?
0.10~0.20초를 초기값으로 두고 테스트하는 방법이 안전하다. 빠른 격투 게임은 짧게, 애니메이션 전환이 긴 3D 액션 게임은 약간 길게 설정할 수 있지만 장르보다 실제 행동의 회복 프레임과 체감이 더 중요하다.
입력 버퍼는 Update()와 FixedUpdate() 중 어디에서 처리해야 하나?
버튼 입력과 상태 기반 행동 전환은 일반적으로 Update()에서 처리한다. 물리 이동 결과가 필요한 점프·착지 판정은 FixedUpdate()의 결과를 캐릭터 상태에 반영하되 입력 수집 자체를 물리 프레임에 묶지 않는 편이 입력 손실을 줄인다.
Command 패턴이 작은 프로젝트에는 과한가?
행동이 2~3개뿐이라면 단순한 열거형 버퍼도 충분하다. 하지만 공격 캔슬, 콤보, 모션 입력, AI 재사용, 리플레이 같은 요구가 생길 가능성이 있다면 입력 생성과 실행을 분리하는 Command 패턴의 이점이 빠르게 커진다.
마무리
선입력 시스템의 핵심은 입력을 늦게 실행하는 것이 아니라 입력 시점의 의도를 잃지 않고 올바른 실행 가능 시점까지 보존하는 데 있다. Command 패턴으로 입력 수집, 버퍼 정책, 상태 조건, 실제 행동 실행을 분리하면 콤보와 캔슬 규칙이 늘어나도 시스템을 예측 가능하게 확장할 수 있다.


