무엇을 만들었나What I Built
UNET의 권한(Authority) 제어와 제네릭 FSM 패턴을 결합하여, 확장 가능하고 안정적인 2D 네트워크 캐릭터 컨트롤러를 설계하는 것을 목표로 했다.
The goal was to combine UNET authority checks with a generic FSM pattern to build a clean, extensible 2D networked character controller.
| 항목 | 내용 |
|---|---|
| 엔진 | Unity 2018.3.12f1 |
| 기여도 | 100% (1인 개발) |
| 네트워크 | UNET (isLocalPlayer 권한 분리 · [Command] · [ClientRpc]) |
| 상태 관리 | 제네릭 StateMachine<T> 직접 구현 (Animator 미사용) |
| 상태 | 완성 |
| Item | Details |
|---|---|
| Engine | Unity 2018.3.12f1 |
| Role | 100% (Solo development) |
| Networking | UNET (isLocalPlayer authority check · [Command] · [ClientRpc]) |
| State Management | Custom generic StateMachine<T> (no Unity Animator) |
| Status | Completed |

- 제네릭 FSM: 타입 안정성(
Type-Safe)과 소유권(Owner) 접근을 갖춘 커스텀 상태 머신 시스템을 구현했다. - 2D 애니메이션 네트워크 동기화: Animator 컴포넌트에 의존하지 않고, 각 상태(Idle · Run · Jump)에 맞는
SpriteRenderer를[Command]와[ClientRpc]로 동기화했다. - 입력 및 물리 동기화: 로컬 플레이어의 키 입력에 따른
Rigidbody2D기반 이동·점프 물리 연산을 서버를 거쳐 브로드캐스트했다.
- Generic FSM: Built a custom type-safe state machine with direct
Owneraccess from inside each state. - 2D Sprite Network Sync: Synchronized the active
SpriteRendererfor each state (Idle, Run, Jump) via[Command]and[ClientRpc]without relying on an Animator component. - Input and Physics Sync: Broadcast
Rigidbody2Dmovement and jump actions triggered by the local player through the server.
상태를 클래스로: PlayerObject 와 StateStates as Classes: PlayerObject and State
PlayerObject 는 내부에 FSM 인스턴스를 하나 들고 있다. IdleState, RunState, JumpState 각각이 자기 클래스이고, 입력 처리 결과에 따라 Invoke<STATE>() 로 다른 상태로 넘어간다.
PlayerObject holds an instance of the state machine. IdleState, RunState, and JumpState are each separate classes, and input inside a state triggers a transition by calling Invoke<STATE>().
private StateMachine<PlayerObject> _stateMachine = null;
void Start ()
{
ShowSprite( eSprite.Idle );
if( !isLocalPlayer ) return;
_stateMachine = new StateMachine<PlayerObject>( this );
StartCoroutine( _stateMachine.Coroutine<IdleState>() );
}
// RunState의 Update 예시
protected override void Update()
{
if( Input.GetKey( KeyCode.LeftArrow ) )
{
Owner.CmdMove( true );
Owner.CmdLookAt( true );
}
else if( Input.GetKey( KeyCode.RightArrow ) )
{
Owner.CmdMove( false );
Owner.CmdLookAt( false );
}
else
{
Invoke<IdleState>();
}
if( Input.GetKey( KeyCode.Space ) ) Invoke<JumpState>();
}
private StateMachine<PlayerObject> _stateMachine = null;
void Start ()
{
ShowSprite( eSprite.Idle );
if( !isLocalPlayer ) return;
_stateMachine = new StateMachine<PlayerObject>( this );
StartCoroutine( _stateMachine.Coroutine<IdleState>() );
}
// Example: Update inside RunState
protected override void Update()
{
if( Input.GetKey( KeyCode.LeftArrow ) )
{
Owner.CmdMove( true );
Owner.CmdLookAt( true );
}
else if( Input.GetKey( KeyCode.RightArrow ) )
{
Owner.CmdMove( false );
Owner.CmdLookAt( false );
}
else
{
Invoke<IdleState>();
}
if( Input.GetKey( KeyCode.Space ) ) Invoke<JumpState>();
}
Start() 에서 !isLocalPlayer 면 상태 머신을 아예 만들지 않는다. 남의 캐릭터는 입력을 읽을 필요가 없고, 화면에 보이는 스프라이트만 서버가 알려 주는 대로 따라가면 되기 때문이다. 대신 스프라이트 초기화(ShowSprite)는 그 검사 앞에 있어서 모든 클라이언트에서 실행된다.
In Start(), if !isLocalPlayer is true, the script does not even instantiate the state machine. Remote characters do not need to read local keyboard input; they only need to display whichever sprite the server tells them to show. Notice that ShowSprite(eSprite.Idle) runs before the !isLocalPlayer return so every client initializes the default sprite.
RunState 의 Update 를 보면 상태 안에서 Owner.CmdMove() 를 바로 호출한다. 상태 클래스가 자기를 소유한 객체를 알고 있어서 가능한 형태다.
Inside RunState.Update(), the state calls Owner.CmdMove() directly. Giving each state a typed reference to its Owner makes that possible without casting.
Animator 를 쓰지 않고 스프라이트를 바꾼다Swapping Sprites Without an Animator
[Command]
private void CmdShowSprite(eSprite type)
{
RpcShowSprite( type );
}
[ClientRpc]
private void RpcShowSprite( eSprite type )
{
if( !isClient ) return;
ShowSprite( type );
}
private void ShowSprite( eSprite type )
{
HideAllSprite();
switch( type )
{
case eSprite.Idle: _spriteIdle.enabled = true; break;
case eSprite.Run: _spriteRun.enabled = true; break;
case eSprite.Jump: _spriteJump.enabled = true; break;
}
}
상태가 바뀌면 클라이언트가 CmdShowSprite 로 서버에 알리고, 서버는 RpcShowSprite 로 모든 클라이언트에게 그 유저의 스프라이트를 바꾸라고 명령한다. ShowSprite 는 전부 끈 뒤 해당 상태의 SpriteRenderer 하나만 켠다.
When the local player's state changes, the client notifies the server via CmdShowSprite, and the server calls RpcShowSprite to tell every client to update that character's sprite. ShowSprite disables all child renderers and enables only the SpriteRenderer matching the active state.
[Command] 로 보낼 수 있다. 동기화 대상이 '애니메이션'이 아니라 '상태'가 되는 셈이다.[Command]. You end up synchronizing the logical state itself rather than trying to sync animation parameters.제네릭 상태 머신의 뼈대Skeleton of the Generic State Machine
구조체나 enum 방식의 단순 조건문 분기를 탈피하려고 만든 제네릭 기반 상태 머신이다. Owner 접근성을 확보해서 각 상태 내부에서 대상 객체의 메서드를 직접 부를 수 있도록 설계했다.
I wrote this generic state machine to move away from long enum-based switch statements. Giving each state a typed Owner property lets state classes invoke methods on the host object directly.
// BaseStateMachine 클래스의 주요 상태 전환 메서드
virtual protected STATE ChangeState<STATE>() where STATE : BASE_STATE_TYPE, new()
{
StopState( _currentStateType );
SetCurrentState<STATE>();
return (STATE)_currentState;
}
// BaseState 클래스의 코루틴 및 Update 루프
private IEnumerator Coroutine()
{
Begin();
yield return Enter();
while( _bActive )
{
yield return null;
Update();
}
End();
}
// Core state transition method in BaseStateMachine
virtual protected STATE ChangeState<STATE>() where STATE : BASE_STATE_TYPE, new()
{
StopState( _currentStateType );
SetCurrentState<STATE>();
return (STATE)_currentState;
}
// Coroutine lifecycle and Update loop in BaseState
private IEnumerator Coroutine()
{
Begin();
yield return Enter();
while( _bActive )
{
yield return null;
Update();
}
End();
}
where STATE : BASE_STATE_TYPE, new() 제약 덕분에 잘못된 타입을 넘기면 컴파일 단계에서 걸린다. 상태 하나의 수명은 코루틴 하나가 통째로 들고 있다. Begin → Enter → (Update 반복) → End 순서가 코드에 그대로 드러나서, 상태를 벗어날 때 정리해야 할 것을 End 한곳에 모을 수 있다.
The where STATE : BASE_STATE_TYPE, new() constraint catches invalid state types at compile time. A single coroutine manages the full lifecycle of an active state: Begin → Enter → repeated Update ticks → End. Having that lifecycle explicit in one loop makes it easy to put all cleanup code inside End.
정리Wrap-Up
- 유니티 내장 Animator 를 쓰지 않고 상태 머신 코드를 직접 짜 보면서, 상태 패턴이 코드를 어떻게 나누는지 손으로 익혔다.
- 이 구조를 UNET RPC 로직과 연결하면서, 캐릭터의 애니메이션과 행동 상태를 멀티플레이어 환경에서 통제하고 동기화하는 기본 흐름을 잡았다.
- 상태를 클래스로 분리하고
Owner를 들려 주는 구조는, 상태 안에서 네트워크 호출을 바로 하게 해 준다는 점에서 네트워크 게임과 잘 맞았다.
- Writing a state machine from scratch without Unity's built-in Animator gave me hands-on practice with how the State pattern separates behavior into manageable pieces.
- Connecting those state transitions to UNET RPC calls established a clear flow for controlling and syncing character actions across a multiplayer session.
- Giving each state class a typed
Ownerreference paired naturally with networked games, since states could invoke network commands directly.
거대한 switch 문 하나로 캐릭터를 돌리던 방식에서 벗어나, 상태마다 클래스를 나누고 그 전환 자체를 네트워크 메시지로 태워 보낸 첫 시도였다. 규모가 작아도 구조를 먼저 잡고 들어가면 이후에 손대기가 얼마나 편해지는지 확인한 프로젝트다.
This was my first time moving away from a giant switch statement, splitting each character state into its own class, and sending the state transitions themselves over the network. Even on a small prototype, setting up that structure first made adding and debugging states much easier.