반응형

네트워크 멀티 플레이에서 상대방 캐릭터의 애니메이션이 플레이 되도록 해 보자.

기본적인 내용은 아래 링크를 참고한다.

2026.09.07 - [Godot] - [Godot] RPG Top-Down View Character 탑다운 뷰 캐릭터 1

2026.09.17 - [Godot] - [Godot] RPG Top-Down View Character 탑다운 뷰 캐릭터 2

2026.09.09 - [Godot] - [Godot] Network Multi Play - Chating 네트워크 멀티 플레이 - 채팅

2026.09.10 - [Godot] - [Godot] Network Multi Play - Character Spawn and Move 네트워크 멀티 플레이 - 캐릭터 소환 및 이동

 

크게 보자면 캐릭터 소환 및 이동 예제에서 SyncAnimDirection, SyncAnimState 변수만 추가로 동기화 시킨다고 생각하면 된다.

 

캐릭터의 스크립트는 아래와 같다.

using Godot;

public partial class CharacBody : CharacterBody2D
{
    public const float Speed = 100.0f;

    private AnimationTree _animationTree;
    private AnimationNodeStateMachinePlayback _stateMachine;

    [Export]
    public Vector2 SyncPosition { get; set; }
    [Export]
    public Vector2 SyncAnimDirection { get; set; }
    [Export]
    public string SyncAnimState { get; set; } = "Idle";

    // _Ready() 함수보다 먼저, 씬에 등장하는 순간에 실행되는 내장 함수.
    public override void _EnterTree()
    {
        // 서버가 내 이름을 특정 번호(예: "1" 또는 "34567")로 지어준다.
        // 그 이름(Name)을 다시 정수(int)로 바꾼다.
        int myAuthorityId = int.Parse(Name);

        // 그 숫자로 내 조종 권한(Authority)을 스스로 설정한다.
        // 이 타이밍(_EnterTree)에 설정해야 MultiplayerSynchronizer가 안정적으로 작동한다.
        SetMultiplayerAuthority(myAuthorityId);
    }

    public override async void _Ready()
    {
        // StateMachine을 가져온다
        _animationTree = GetNode<AnimationTree>("AnimationTree");
        _stateMachine = (AnimationNodeStateMachinePlayback)_animationTree.Get("parameters/playback");

        // 게임 시작 시 기본 방향을 아래쪽(0, 1)으로 강제 설정.
        // Y축이 아래쪽으로 향하는 2D 좌표계에서, (0, 1)은 아래쪽을 의미.
        Vector2 startingDirection = new Vector2(0, 1);

        _animationTree.Set("parameters/Idle/blend_position", startingDirection);
        _animationTree.Set("parameters/Walk/blend_position", startingDirection);

        // Area2D 노드의 BodyEntered 시그널에 이벤트 핸들러를 연결한다.
        Area2D area2D = GetNode<Area2D>("Area2D");
        area2D.BodyEntered += OnBodyEntered;

        // 처음 실행되는 서버의 경우 상관 없지만 클라이언트의 경우 서버에 접속해서 캐릭터가 스폰되고 나서 다른 컴퓨터의
        // 캐릭터 정보(이 예의 경우 위치 정보인 SyncPosition)가 네트워크로 전송되기까지 약간의 딜레이가 있다.
        // 그 사이 _PhysicsProcess가 호출되면 기본 위치(0,0)에서 시작해서 SyncPosition으로 이동하는 과정이 화면에 그대로
        // 보이게 된다. 따라서 다른 컴퓨터의 캐릭터가 내 화면에 처음 등장할 때는, SyncPosition이 네트워크로 전송되기
        // 전까지는 잠시 대기하는 시간이 필요하다. (아래 코드 참고)

        // 물리 엔진 스위치 끄기. _PhysicsProcess 함수가 아예 호출되지 않게 만든다.
        SetPhysicsProcess(false);

        // 목표 위치를 현재 위치로 초기화.
        // 다른 사람의 화면에서 내 캐릭터가 처음 등장할 때 SyncPosition이 아래와 같이
        // 초기화되지 않은 상태라면 (0,0) 좌표로 날아가 버린다.
        SyncPosition = GlobalPosition;

        // 0.5초 동안 기다린다. 0.1초도 상관 없다. 다른 컴퓨터의 캐릭터 정보(SyncPosition)가 업데이트 되는 시간이면 된다.
        // 시작하자마자 물리 연산이 멈춰있으므로 공중에서 대기한다.
        await ToSignal(GetTree().CreateTimer(0.5f), SceneTreeTimer.SignalName.Timeout);

        // 내 캐릭터면 정상 색깔(흰색), 다른 사람의 캐릭터면 빨간색으로 칠하기.
        // 내 카메라는 켜고 다른 사람의 카메라는 끈다.
        if (IsMultiplayerAuthority())
        {
            Modulate = Colors.White; // 내 캐릭터는 흰색으로 칠해 구분하기 쉽게 한다.

            // 내 캐릭터의 카메라를 켜고 활성화 시킨다.
            GetNode<Camera2D>("Camera2D").Enabled = true;
            GetNode<Camera2D>("Camera2D").MakeCurrent();
        }
        else
        {
            Modulate = Colors.IndianRed; // 상대방 캐릭터는 빨간색으로 칠해 구분하기 쉽게 한다.

            // 다른 사람의 캐릭터(기존에 방에 있던 사람)가 내 화면에 처음 스폰될 때는
            // Lerp로 미끄러져 오지 않도록, 네트워크로 받은 위치(SyncPosition)로 즉시 이동.
            // 충분한 시간이 지나서 SyncPosition이 업데이트 된 후 실행해야 한다.
            GlobalPosition = SyncPosition;

            // 다른 사람의 캐릭터 카메라는 꺼야 한다. (카메라는 한 화면에 하나만 존재해야 한다.)
            // 내 권한의 캐릭터가 아닌 다른 사람의 캐릭터이므로 카메라를 끄면 권한을 가진 캐릭터의 카메라만 남게 된다.
            GetNode<Camera2D>("Camera2D").Enabled = false;
        }

        // 엔진 스위치 켜기: 0.5초가 지났으니 이제부터 매 프레임 _PhysicsProcess를 실행하라고 지시한다.
        SetPhysicsProcess(true);
    }

    private void OnBodyEntered(Node body)
    {
        Godot.Collections.Array<StringName> groups = body.GetGroups();

        if (groups != null && groups.Count > 0)
        {
            foreach (StringName group in groups)
            {
                switch (group)
                {
                    case "Flowers":
                        GD.Print("Flower entered");
                        break;
                    case "Rocks":
                        GD.Print("Rock entered");
                        break;
                    default:
                        GD.Print("Other group entered: " + group.ToString());
                        break;
                }
            }
        }
    }

    public override void _PhysicsProcess(double delta)
    {
        // 엔진에게 이 캐릭터가 내 조종 권한인지 물어본다.
        // 내가 주인이 아니라면(false) 다른 사람의 캐릭터이고, 그 캐릭터의 목표 위치(SyncPosition)를 향해 부드럽게(Lerp) 다가간다.
        // 그리고 아래에 있는 키보드 입력과 이동 코드는 아예 실행하지 않고 끝낸다.
        if (!IsMultiplayerAuthority())
        {
            // 소수점 진동 방지. 나와 목표 위치의 거리가 1픽셀보다 작다면? (거의 다 왔다면)
            if (GlobalPosition.DistanceTo(SyncPosition) < 1.0f)
            {
                // 진동하지 않고 그냥 목표 위치에 딱 붙여버린다.
                GlobalPosition = SyncPosition;
            }
            else
            {
                // 거리가 멀 때만 부드럽게(Lerp) 따라간다.
                // 15.0f는 다가가는 속도다. 이 숫자를 조절해서 미끄러짐의 정도를 바꿀 수 있다.
                GlobalPosition = GlobalPosition.Lerp(SyncPosition, 15.0f * (float)delta);
            }

            // 애니메이션 동기화. 네트워크로 넘어온 상대방 캐릭터의 방향과 상태를 내 화면의 트리(Tree)에 적용.
            _animationTree.Set("parameters/Idle/blend_position", SyncAnimDirection);
            _animationTree.Set("parameters/Walk/blend_position", SyncAnimDirection);
            _animationTree.Set("parameters/Plow/blend_position", SyncAnimDirection);
            _stateMachine.Travel(SyncAnimState);

            return;
        }

        // 캐릭터의 AnimationTree 노드 설정창에서 Plow -> Idle 방향으로 전환되는 화살표(Transition)이 존재해야 하고
        // 그 화살표의 Inspector 창에서 Advance - Mode를 "Auto"로 설정해야 한다.
        // 그래야 Plow 애니메이션이 끝나면 자동으로 Idle 상태로 전환된다.        
        if (_stateMachine.GetCurrentNode() == "Plow") // 애니메이션 락(Lock): 현재 상태 머신이 'Plow'라면..
        {
            // 움직이지 못하게 속도를 0으로 고정.
            Velocity = Vector2.Zero;

            // 혹시 외부 요인에 밀려날 수 있으니 위치는 계속 동기화.
            SyncPosition = GlobalPosition;

            // 아래의 키보드 입력과 Walk/Idle 전환 코드를 모두 무시하고 여기서 프레임 종료.
            return;
        }

        // IsActionJustPressed는 계속 누르고 있어도 클릭하는 순간 1번만 true를 반환한다.
        // Plow 액션은 Input Map에서 마우스 왼쪽 버튼으로 설정되어 있다.        
        if (Input.IsActionJustPressed("Plow"))
        {
            Vector2 rawDirection = GetGlobalMousePosition() - GlobalPosition;
            Vector2 plowDirection = Vector2.Zero;

            if (Mathf.Abs(rawDirection.X) > Mathf.Abs(rawDirection.Y))
                plowDirection = rawDirection.X > 0 ? Vector2.Right : Vector2.Left;
            else
                plowDirection = rawDirection.Y > 0 ? Vector2.Down : Vector2.Up;

            // Plow 애니메이션을 재생하기 위해, 애니메이션 트리의 blend_position을 설정한다.
            _animationTree.Set("parameters/Plow/blend_position", plowDirection);
            // Walk과 Idle 애니메이션도 같은 방향으로 바라보도록 blend_position을 설정한다.
            _animationTree.Set("parameters/Idle/blend_position", plowDirection);
            _animationTree.Set("parameters/Walk/blend_position", plowDirection);
            _stateMachine.Travel("Plow");

            // SyncAnimDirection과 SyncAnimState를 설정해서 다른 사람에게도 Plow 애니메이션이 재생되도록 한다.
            SyncAnimDirection = plowDirection;
            SyncAnimState = "Plow";

            // Plow 명령을 내렸으니 return으로 함수를 끝내서 아래에 있는 Idle(대기) 상태가 되는것을 차단한다.
            return;
        }

        // WASD 또는 방향키 입력을 Vector2로 받아온다.
        // 예: 오른쪽 위 대각선 이동 시 (1, -1) 반환
        Vector2 direction = Input.GetVector("ui_left", "ui_right", "ui_up", "ui_down");

        if (direction != Vector2.Zero)
        {
            // 이동 방향을 애니메이션 트리에 전달.
            _animationTree.Set("parameters/Idle/blend_position", direction);
            _animationTree.Set("parameters/Walk/blend_position", direction);
            // 상태를 '걷기'로 바꾼다.
            _stateMachine.Travel("Walk");

            // SyncAnimDirection과 SyncAnimState를 설정해서 다른 사람에게도 Walk 애니메이션이 재생되도록 한다.
            SyncAnimDirection = direction;
            SyncAnimState = "Walk";
        }
        else
        {
            // 입력이 없으면 상태를 '대기'로 바꾼다. 
            // (마지막으로 바라보던 방향의 idle 애니메이션이 자동으로 재생)
            _stateMachine.Travel("Idle");

            // SyncAnimState를 설정해서 다른 사람에게도 Idle 애니메이션이 재생되도록 한다.
            SyncAnimState = "Idle";
        }

        // 입력된 방향에 속도를 곱해서 바로 Velocity(속력)로 설정한다.
        Velocity = direction * Speed;

        // 엔진이 알아서 벽 충돌을 계산하며 이동시킨다.
        MoveAndSlide();

        // 내 캐릭터의 이동이 다 끝난 후 내 최종 위치를 목표 위치 변수에 담아 다른 사람에게 보내준다.
        // MultiplayerSynchronizer가 자동으로 SyncPosition을 다른 사람에게 보내준다.
        SyncPosition = GlobalPosition;
    }
}

 

 

캐릭터 AnimationTree - Plow -> Idle 방향의 Transition - Advance - Mode - Auto로 설정한다.

Auto로 설정하면 Plow 애니메이션이 끝나고 자동으로 Idle 애니메이션으로 돌아간다. 이걸 하지 않으면 스크립트에 의해 Plow에서 Idle로 돌아가는 코드가 없기 때문에 Plow 애니메이션 마지막에 멈추게 되고 더 이상 조작 할 수 없게 된다.

 

 

MultiplayerSynchronizer - Replication에 SyncAnimDirection과 SyncAnimState를 추가한다.

 

 

서버-클라이언트 스크립트는 아래와 같다.

using Godot;

public partial class TestStage : Control
{
    private const int Port = 8910; // 포트 번호 (임의의 4자리 숫자)
    private const string ServerIp = "127.0.0.1"; // 내 컴퓨터 안에서 테스트할 주소 (로컬호스트)

    // 캐릭터 씬과 캐릭터 컨테이너 노드를 담을 변수 추가
    private PackedScene _playerScene = GD.Load<PackedScene>("res://test.tscn"); // 캐릭터 씬
    private Node2D _playersContainer; // 캐릭터들을 담을 컨테이너 노드 (Node2D)

    // Called when the node enters the scene tree for the first time.
    public override void _Ready()
    {
        GetNode<Button>("Button").Pressed += _on_host_button_pressed;
        GetNode<Button>("Button2").Pressed += _on_join_button_pressed;

        // 컨테이너 노드 가져오기, 컨테이너 노드는 특별한 기능을 하는 노드가 아니라 단순히 캐릭터들을 담는 용도의 Node2D다.
        _playersContainer = GetNode<Node2D>("Node2D");

        // 누군가 방에 성공적으로 들어왔을 때 모든 사람의 컴퓨터에서 실행된다.
        // 방장과 이미 방에 있던 사람들 뿐만 아니라, 새로 들어온 사람의 컴퓨터에서도 실행된다.
        Multiplayer.PeerConnected += OnPeerConnected;

        // 누군가 접속을 끊고 나갔을 때 나간 사람을 제외한 모든 사람의 컴퓨터에서 실행된다.
        Multiplayer.PeerDisconnected += OnPeerDisconnected;

        // 클라이언트 전용: 내가 서버에 성공적으로 접속 완료했을 때 실행
        Multiplayer.ConnectedToServer += OnConnectedToServer;

        // 클라이언트 전용: 내가 서버에 접속 시도했는데 실패했을 때 실행
        Multiplayer.ConnectionFailed += OnConnectionFailed;

        // 클라이언트 전용: 서버와 연결이 끊어졌을 때 (방장이 나갔을 때)
        Multiplayer.ServerDisconnected += OnServerDisconnected;
    }

    // 스폰 함수 (서버에서만 실행됨)
    private void SpawnPlayer(long id)
    {
        // 캐릭터를 메모리에 하나 만든다.
        Node2D player = _playerScene.Instantiate<Node2D>();

        // 나중에 네트워크 권한(Authority)을 주기 위해 노드 이름을 ID 숫자로 바꾼다.
        // Cat.tscn 씬에서 _EnterTree() 함수가 실행될 때, 노드 이름(Name)을 정수(int)로 바꿔서 SetMultiplayerAuthority()를 호출한다.
        player.Name = id.ToString();

        // 캐릭터를 컨테이너에 넣는다. 이 순간 MultiplayerSpawner 노드가 알아채고 손님 화면에도 똑같이 복사해 준다.
        // MultiplayerSpawner 노드는 코드에 등장하지 않아 아무것도 안하는거 같지만 서버가 자신의 캐릭터 컨테이너에 캐릭터를
        // 넣거나 빼면, 각 클라이언트 컨테이너에도 똑같이 넣거나 빼준다.
        _playersContainer.AddChild(player);
    }

    // Host 버튼을 눌렀을 때
    public void _on_host_button_pressed()
    {
        // 네트워크 통신을 담당할 'Peer(피어)' 객체 생성
        ENetMultiplayerPeer peer = new ENetMultiplayerPeer();

        // 서버 열기 (최대 4명 접속 가능)
        Error error = peer.CreateServer(Port, 4);
        if (error != Error.Ok)
        {
            GD.Print($"[시스템] 서버 열기 실패!");
            return;
        }

        // 씬 트리의 멀티플레이어 관리자에게 이 Peer를 등록
        Multiplayer.MultiplayerPeer = peer;
        GD.Print($"[ID: {Multiplayer.GetUniqueId()}] [시스템] 서버가 열렸습니다! 내 고유 ID: {Multiplayer.GetUniqueId()}");

        // 서버가 열리면 방장 본인의 캐릭터를 소환한다.
        // 서버의 ID는 항상 1번이다. 그 외 클라이언트들은 예상 할 수 없는 큰 무작위 난수가 부여된다. (해킹 방지)        
        SpawnPlayer(Multiplayer.GetUniqueId());
        // 서버가 항상 1번이므로 클라이언트는 RpcId()를 사용해 서버에게 특별한 요청을 할 수 있다.
        // RpcId(1, MethodName.RequestOpenChest, chestId); // 예시: 서버에게 요청을 보내는 코드

        GetNode<Button>("Button").Disabled = true; // Host 버튼 비활성화
        GetNode<Button>("Button2").Disabled = true; // Join 버튼 비활성화
    }

    // 접속(Join) 버튼을 눌렀을 때
    public void _on_join_button_pressed()
    {
        ENetMultiplayerPeer peer = new ENetMultiplayerPeer();

        // IP 주소와 포트 번호로 서버에 접속
        Error error = peer.CreateClient(ServerIp, Port);
        if (error != Error.Ok)
        {
            GD.Print($"[시스템] 접속 시도 불가! 에러 코드: {error}");
            return;
        }

        Multiplayer.MultiplayerPeer = peer;
        GD.Print($"[ID: {Multiplayer.GetUniqueId()}] [시스템] 서버에 접속을 시도합니다... (응답 대기 중)");
    }

    // 누군가 방에 들어왔을 때 방장과 이미 방에 있던 사람들 뿐만 아니라, 새로 들어온 사람의 컴퓨터에서도 실행된다.
    // 즉, 모든 사람의 컴퓨터에서 실행된다.
    // Godot 엔진에서 Multiplayer.PeerConnected 시그널은 "누군가 방에 새로 들어왔다!"라는 뜻이 아니다.
    // 정확한 의미는 "나와 저 사람 사이에 통신 케이블(연결)이 방금 연결되었다!"이다.
    // 그래서 새로 들어온 사람의 컴퓨터에서도 실행된다.
    private void OnPeerConnected(long id)
    {
        if (Multiplayer.IsServer())
        {
            // 방장인지 확인하고, 방장일 때만 접속한 사람의 ID와 안내 메시지를 출력한다.
            // 방장 권한으로, 방에 있는 모두의 화면에 접속 메시지를 띄우라고 명령(RPC)한다.
            Rpc(MethodName.BroadcastMessage, $"[시스템] 플레이어 {id} 님이 방에 새로 합류했습니다!");

            // 새로운 클라이언트가 들어오면, 서버가 그 클라이언트의 아이디로 캐릭터를 소환해 준다.
            SpawnPlayer(id);
        }
    }

    private void OnPeerDisconnected(long id)
    {
        GD.Print($"[ID: {Multiplayer.GetUniqueId()}] [시스템] 플레이어 {id} 님이 방을 나갔습니다.");

        // 누군가 방을 나가면, 서버가 해당 아이디의 캐릭터를 찾아서 삭제한다.
        if (Multiplayer.IsServer())
        {
            Node playerToDestroy = _playersContainer.GetNodeOrNull(id.ToString());
            if (playerToDestroy != null)
            {
                playerToDestroy.QueueFree();
            }
        }
    }

    private void OnServerDisconnected()
    {
        GD.Print($"[시스템] 방장(서버)과의 연결이 끊어졌습니다. 방이 폭파되었습니다.");
        // 방장이 나갔으므로 네트워크 상태를 깨끗하게 초기화 한다.
        Multiplayer.MultiplayerPeer = null;
    }

    // RPC (Remote Procedure Call, 원격 프로시저 호출)
    // RpcMode.Authority는 '방장(권위자)만 이 함수를 호출할 수 있다'는 뜻이다.
    // CallLocal = true는 '방장 자신의 화면에서도 이 함수를 실행하라'는 뜻이다.
    // 따라서 방장이 이 함수를 호출하면 방장의 화면과 접속한 모든 사람의 화면에서 동시에 실행된다.
    // RpcMode.AnyPeer는 '누구나 이 함수를 호출할 수 있다'는 뜻이다.
    // RpcMode.Disabled는 '이 함수를 RPC로 호출할 수 없다'는 뜻이다.
    [Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = true)]
    public void BroadcastMessage(string message)
    {
        // 방장의 명령을 받아 모두의 화면에서 동시에 실행된다. OnPeerConnected() 함수에서 방장이 사용.
        GD.Print($"[ID: {Multiplayer.GetUniqueId()}] " + message);
    }

    // 내가 서버에 성공적으로 접속했을 때 (클라이언트에서만 실행됨)
    private void OnConnectedToServer()
    {
        GD.Print($"[ID: {Multiplayer.GetUniqueId()}] [시스템] 서버 접속 성공!");

        GetNode<Button>("Button").Disabled = true; // Host 버튼 비활성화
        GetNode<Button>("Button2").Disabled = true; // Join 버튼 비활성화
    }

    // 내가 서버에 접속 시도했는데 실패했을 때 (클라이언트에서만 실행됨)
    private void OnConnectionFailed()
    {
        // 방장이 없거나, IP가 틀렸거나, 방화벽에 막혔을 때 출력된다.
        GD.Print($"[시스템] 서버 접속에 실패했습니다! (방이 없거나 응답 없음)");
        // 실패했으므로 네트워크 상태를 깨끗하게 초기화 한다.
        Multiplayer.MultiplayerPeer = null;
    }
}

 

 

고양이 캐릭터는 Motion Mode가 Floating 이므로 중력이 작용되지 않기 때문에 지형은 필요 없다.

 

게임을 실행하면 서버와 클라이언트의 캐릭터 애니메이션이 동기화 된다.

 

반응형
Posted by J-sean
: