프로젝트 요약UNET을 활용한 2D 멀티플레이어 플랫포머 프로토타입이다. 직접 설계한 제네릭 기반 FSM(유한 상태 기계)을 네트워크 환경에 접목해, 플레이어의 이동·점프·2D 애니메이션 상태를 클라이언트 간에 동기화하는 로직을 구축했다.
Project SummaryA 2D multiplayer platformer prototype built with UNET. I designed a custom generic finite state machine (FSM) and hooked it into UNET so player movement, jumping, and 2D sprite states stayed synchronized across clients.

무엇을 만들었나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 미사용)
상태완성
Swipe horizontally to view the full table
ItemDetails
EngineUnity 2018.3.12f1
Role100% (Solo development)
NetworkingUNET (isLocalPlayer authority check · [Command] · [ClientRpc])
State ManagementCustom generic StateMachine<T> (no Unity Animator)
StatusCompleted
Hierarchy에 PlayerObject(Clone)가 있고 Project 창에 BaseState 계열 스크립트가 보이는 유니티 에디터 화면
네트워크로 스폰된 `PlayerObject(Clone)` 와, Assets/Script 아래의 상태 머신 스크립트들. 플레이 화면은 원작 스프라이트를 참고용으로 쓴 것이라 싣지 않았다.Network-spawned `PlayerObject(Clone)` in the Hierarchy and state machine scripts under Assets/Script. The Game view is omitted because it used reference sprites from an existing game.

상태를 클래스로: 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.

상태를 코드가 들고 있으면Animator 를 쓰면 상태 전이가 애니메이터 그래프와 코드 두 군데에 나뉜다. 상태를 코드에서 전부 들고 있으면 그 전이를 그대로 [Command] 로 보낼 수 있다. 동기화 대상이 '애니메이션'이 아니라 '상태'가 되는 셈이다.
When Code Owns the StateWhen you use Unity's Animator, state transitions are split between the Animator graph and your C# scripts. When code owns the state machine outright, every transition can be sent directly as a [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

거대한 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.