[키 바인딩 시스템] 키 서비스

Jongmin Kim·2025년 8월 12일

Lapi

목록 보기
6/14

시작

데스크탑 게임에 있어서 키 바인딩 시스템은 사용자들의 자유도와 컨트롤을 높이는 데 큰 기여를 한다.

사람들마다 선호하는 키 조합과 배열이 다르기 때문에 사용자들의 만족도를 높이려면 키보드의 키를 개개인이 바인딩할 수 있어야 한다.

본격적으로 다양한 팝업 UI의 활성화 및 비활성화를 키보드로 입력하기 전에 키 바인딩 시스템을 만들면 어떨까? 해서 키 바인딩 시스템을 설명하고자 한다.



구조

새로운 시스템이기 때문에 글을 쓰기에 앞서서 먼저 구조를 조금 살펴보자.


키 바인딩 UI을 포함한 팝업 UI의 구현에 사용되는 UI들은 PopupUIManager의 관리를 통해서만 UI를 활성화 및 비활성화할 수 있다.

따라서 PopupUIManager에서 각 팝업 UI들의 프레젠터를 알고 이에 매핑된 키를 통해서 각 팝업 UI Presenter들의 OpenUI() 또는 CloseUI()를 호출할 책임을 가져야 한다.

따라서 각 팝업 UI들의 프레젠터를 PopupUIManager에서 인식할 수 있도록 IPopupPresenter라는 어댑터 인터페이스를 도입한다.


추가적으로 PopupUIManager의 호출로만 비활성화가 가능한 것은 아니다. UI마다 각각의 비활성화 버튼을 지니게 될 수도 있다.

하지만 이를 온전히 PopupUIManager의 책임으로만 물기에는 PopupUIManager의 크기가 비대해진다.

이런 경우에는 UI의 ViewIPopupView를 구현하여 CloseUI()에서 반드시 RemovePresenter()를 호출하도록 해야 한다.



키 데이터 구성

우선 서비스에서 관리하고 제공할 데이터의 형태를 먼저 구성해야 한다. 내가 생각한 데이터 관리 구성에서는 stringUnityEngine.KeyCode를 매핑해야겠다는 생각을 먼저 했다.

따라서 다음과 같은 구조로 KeyData를 설계했다.

// LocalKeyService.cs의 일부

namespace KeyService
{
    #region Serialization
    [System.Serializable]
    public struct KeyData
    {
        public string Name;		// 키에 매핑될 문자열
        public KeyCode Code;

        public KeyData(string name, KeyCode code)
        {
            Name = name;
            Code = code;
        }
    }
    
    // Json은 배열을 읽고 쓰기 어렵기 때문에 한 번 더 래핑하는 과정이 필요하다.
    public class DataWrapper
    {
        public KeyData[] Data;

        public DataWrapper(KeyData[] data)
        {
            Data = data;
        }
    }
	#endregion Serialization
    
    // ...
}

즉, 키 서비스에서는 KeyData를 딕셔너리로 관리하게 될 것이다. 그리고 이 키 서비스를 이용하는 PopupUIManager에서의 동작은 다음과 같을 것이다.



키 서비스

여느 서비스와 마찬가지로 키 서비스도 확장성과 변경성을 위해 인터페이스를 우선적으로 구현한다.

using System;
using UnityEngine;

namespace KeyService
{
    public interface IKeyService
    {
    	// 이전의 키와 달라진 키가 있는 경우에 호출되는 델리게이트다.
        event Action<KeyCode, string> OnUpdatedKey;

		
        void Initialize();	// 초기화를 위해 OnUpdatedKey를 실행시키는 용도로 사용한다.
        void Reset();		// 키를 기본 설정으로 되돌릴 때 사용한다.
        
        KeyCode GetKeyCode(string key_name);			// 문자열에 매핑된 키 코드를 반환한다.
        bool Check(KeyCode key, KeyCode current_key);	// 변경하려는 키가 유효한 키인지 확인한다.
        void Register(KeyCode key, string key_name);	// 유효한 키를 토대로 키를 변경한다.
    }
}

그리고 개발하려는 게임에 맞게 인터페이스를 구체화한다. Lapi는 싱글플레이이므로 로컬 키 서비스를 구체화했다.

namespace KeyService
{
    public class LocalKeyService : ISaveable, IKeyService
    {
        private Dictionary<string, KeyCode> m_key_dict;

        public event Action<KeyCode, string> OnUpdatedKey;

        public LocalKeyService()
        {
            m_key_dict = new();

            CreateDirectory();		// 디렉터리 경로가 없는 경우 새롭게 생성한다.
            Reset();				// 기본 설정 키로 초기화한다.
        }

        private void CreateDirectory()
        {
            var local_directory_path = Path.Combine(Application.persistentDataPath, "Key");

            if (!Directory.Exists(local_directory_path))
            {
                Directory.CreateDirectory(local_directory_path);

#if UNITY_EDITOR
                Debug.Log($"<color=cyan>Key 디렉터리를 새롭게 생성합니다.</color>");
#endif
            }
        }

		// 모든 바인딩된 키를 업데이트한다.
        public void Initialize()
        {
            foreach (var pair in m_key_dict)
            {
                OnUpdatedKey?.Invoke(pair.Value, pair.Key);
            }
        }

		// 기본 설정 키로 초기화한다.
        public void Reset()
        {
            m_key_dict.Clear();

            Register(KeyCode.I, "Inventory");
            Register(KeyCode.U, "Equipment");
            Register(KeyCode.K, "Skill");
            Register(KeyCode.T, "Quest");
            Register(KeyCode.P, "Binder");
            Register(KeyCode.H, "Shortcut");

            Register(KeyCode.Alpha1, "Shortcut0");
            Register(KeyCode.Alpha2, "Shortcut1");
            Register(KeyCode.Alpha3, "Shortcut2");
            Register(KeyCode.Alpha4, "Shortcut3");
            Register(KeyCode.Alpha5, "Shortcut4");

            Register(KeyCode.Z, "Shortcut5");
            Register(KeyCode.X, "Shortcut6");
            Register(KeyCode.C, "Shortcut7");
            Register(KeyCode.V, "Shortcut8");
            Register(KeyCode.B, "Shortcut9");

            Register(KeyCode.Escape, "Pause");
        }

		// 변경하려는 키가 유효한 키인지 확인한다.
        public bool Check(KeyCode key, KeyCode current_key)
        {
        	// 변경하려는 키가 현재 키와 같다면 변경이 가능하다.
            if (current_key == key)
            {
                return true;
            }

			// 키보드 알파벳 자판과 숫자만 가능하다.
            if (KeyCode.A <= key && key <= KeyCode.Z ||
                KeyCode.Alpha0 <= key && key <= KeyCode.Alpha9) { }
            else
            {
                return false;
            }

			// WASD는 이동 키로 예약되어 있으므로 이 키는 바인딩이 불가능하다.
            if (key == KeyCode.W ||
                key == KeyCode.A ||
                key == KeyCode.S ||
                key == KeyCode.D)
            {
                return false;
            }

			// 이미 바인딩이 되어있는 키라면 이 키는 바인딩이 불가능하다.
            foreach (var pair in m_key_dict)
            {
                if (key == pair.Value)
                {
                    return false;
                }
            }

            return true;
        }

		// 입력한 키를 주어진 문자열과 매핑하여 바인딩한다.
        public void Register(KeyCode key, string key_name)
        {
            m_key_dict[key_name] = key;

            OnUpdatedKey?.Invoke(key, key_name);
        }

		// 문자열과 매핑된 키 코드를 반환한다.
        public KeyCode GetKeyCode(string key_name)
        {
            return m_key_dict.TryGetValue(key_name, out var code) ? code : KeyCode.None;
        }

        public bool Load(int offset)
        {
            var local_data_path = Path.Combine(Application.persistentDataPath, "Key", $"KeyData{offset}.json");

            if (File.Exists(local_data_path))
            {
                m_key_dict.Clear();

                var json_data = File.ReadAllText(local_data_path);
                var wrapped_data = JsonUtility.FromJson<DataWrapper>(json_data);

                foreach (var key_data in wrapped_data.Data)
                {
                    Register(key_data.Code, key_data.Name);
                }
            }
            else
            {
                return false;
            }

            return true;
        }

        public void Save(int offset)
        {
            var local_data_path = Path.Combine(Application.persistentDataPath, "Key", $"KeyData{offset}.json");

            var temp_list = new List<KeyData>();
            foreach (var pair in m_key_dict)
            {
                temp_list.Add(new(pair.Key, pair.Value));
            }

            var wrapped_data = new DataWrapper(temp_list.ToArray());
            var json_data = JsonUtility.ToJson(wrapped_data, true);

            File.WriteAllText(local_data_path, json_data);
        }
    }
}



서비스 등록

완성한 로컬 키 서비스를 어디서든 사용할 수 있도록 서비스 로케이터에 등록한다.

// 이전과 동일

public static class ServiceLocator
{
    private static Dictionary<Type, object> m_services = new();

    public static IDictionary<Type, object> Services => m_services;

    public static void Initialize()
    {
        Register<IEXPService>(new LocalEXPService());
        Register<IUserService>(new LocalUserService());
        Register<IKeyService>(new LocalKeyService());
		// 이전과 동일
    }

    public static void Register<T>(T service)
    {
		// 이전과 동일
    }

    public static T Get<T>()
    {
		// 이전과 동일
    }
}



마무리

이번 글에서는 바인딩된 키를 관리하고 키를 새롭게 바인딩할 수 있는 키 서비스를 구현했다. 키 바인딩 시스템에서는 이 키 서비스를 토대로 이야기를 계속 전개해 나갈 예정이다.

다음 글에서는 키 바인딩 시스템을 실제로 사용자가 할 수 있도록 키 바인더 UI를 제작해보자.

profile
Game Client Programmer

0개의 댓글