데스크탑 게임에 있어서 키 바인딩 시스템은 사용자들의 자유도와 컨트롤을 높이는 데 큰 기여를 한다.
사람들마다 선호하는 키 조합과 배열이 다르기 때문에 사용자들의 만족도를 높이려면 키보드의 키를 개개인이 바인딩할 수 있어야 한다.
본격적으로 다양한 팝업 UI의 활성화 및 비활성화를 키보드로 입력하기 전에 키 바인딩 시스템을 만들면 어떨까? 해서 키 바인딩 시스템을 설명하고자 한다.
새로운 시스템이기 때문에 글을 쓰기에 앞서서 먼저 구조를 조금 살펴보자.

키 바인딩 UI을 포함한 팝업 UI의 구현에 사용되는 UI들은 PopupUIManager의 관리를 통해서만 UI를 활성화 및 비활성화할 수 있다.
따라서 PopupUIManager에서 각 팝업 UI들의 프레젠터를 알고 이에 매핑된 키를 통해서 각 팝업 UI Presenter들의 OpenUI() 또는 CloseUI()를 호출할 책임을 가져야 한다.
따라서 각 팝업 UI들의 프레젠터를 PopupUIManager에서 인식할 수 있도록 IPopupPresenter라는 어댑터 인터페이스를 도입한다.
추가적으로 PopupUIManager의 호출로만 비활성화가 가능한 것은 아니다. UI마다 각각의 비활성화 버튼을 지니게 될 수도 있다.
하지만 이를 온전히 PopupUIManager의 책임으로만 물기에는 PopupUIManager의 크기가 비대해진다.
이런 경우에는 UI의 View가 IPopupView를 구현하여 CloseUI()에서 반드시 RemovePresenter()를 호출하도록 해야 한다.
우선 서비스에서 관리하고 제공할 데이터의 형태를 먼저 구성해야 한다. 내가 생각한 데이터 관리 구성에서는 string과 UnityEngine.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를 제작해보자.