메피카타츠의 블로그
Tactical Combat System 개발일지 3 - 클라/서버 공통으로 사용 가능한 이동 시스템 구현 본문
오늘은 어제 계획했던 내용 중 "클라/서버 공통으로 사용 가능한 이동 시스템 구현" 작업을 했다.
아침에 작업에 착수하는 순간 든 생각이 있다. "아씨... 내가 생각한 거보다 작업이 훨씬 커지겠는데?"
그 이후에 약 30분 간 메모장에 써내려간 내용은 아래와 같다.
Entity를 BattleMapSimulator에 추가해야 한다.
그런데 Entity는 공용 라이브러리라 에디터에서 바로 세팅할 수가 없다.
EntityComponent를 추가해서 할 것 같다.
근데 이 EntityComponent에서 파생된 정보를 어떻게 씬 내의 모델과 연결을 할 것인가?
EntityComponent를 Entity 정보로 변환해야 하고, 모델은 BattleMapSimulator 초기화 과정에서 Entity를 생성할 때 별도로 생성해야 할 것 같다.
그럼 Entity마다 Path와 같은 모델 정보를 가지고 있어야 하는데, 결국 데이터 관리 시스템이 필요하다.
테이블 연동은 여러모로 준비할 작업이 많기 때문에, ScriptableObject를 적극 활용하는 방식으로 가야할 것 같다.
1. ScriptableObject를 바로 사용해도 될지, 아니면 별도의 파생 class를 만들어 사용 편의성을 좋게 해야 할지 고민해보자.
2. Entity 이름과 Prefab의 Path를 연결해주는 ScriptableObject를 만들자.
3. EntityComponent를 만들어서 Scene을 저장할 때 Entity로 변환해서 저장해주자.
4. BattleMapSimulator 초기화할 때 BattleMapData에서 데이터를 뽑아오고, Entity 생성하면서 모델 불러오고, 위치 세팅까지 하자.
5. 에디터에서 각 Entity의 시작 Position, 종료 Position을 세팅할 수 있도록 해야할 것 같고 Update 돌리면 이동하도록 하자.
6. Update를 돌려주는 주체는 에디터/서버 각각 따로 관리해야 할 것 같다.
7. BattleMapSimulator에 Event를 등록해서 클라이언트에서 모델 생성, Entity가 이동할 때 모델도 같이 이동해야 할 것 같다.
8. 7번을 위한 class들을 만들어야 할 것 같다. BattleMapSimulator와는 별도로 관리해줄 class가 필요할 것 같다. -_-;
일단 이 정도만 하면 목표 달성은 가능할 듯...
그래서 결국에는 ScriptableObject를 편하게 사용하기 위한 base class와, Entity 정보를 세팅하기 위한 Component 작업, 클라이언트에서 모델 로드/이동/회전 등을 담당할 class까지 만들어 이벤트 처리까지 담당하는 처리까지 했다. (써놓고보니 별로 많지 않은 것 같기도... -_-)
먼저 ScriptableObject를 그대로 사용하는 것도 가능하겠지만, 편하게 생성하고 불러오기 위해서는 별도의 base class를 만드는 것이 맞을 것 같다고 생각했다.
using System;
using Script.CommonLib;
using UnityEngine;
namespace Script.ClientLib
{
public abstract class ScriptableObjectBase : ScriptableObject
{
public static string GetAssetPath(string assetName)
{
return $"Assets/Data/ScriptableObject/{assetName}.asset";
}
public static ScriptableObjectBase GetScriptableObject(Type type)
{
#if UNITY_EDITOR
var path = GetAssetPath(type.Name);
IOHelper.EnsureDirectory(path);
var asset = UnityEditor.AssetDatabase.LoadAssetAtPath<ScriptableObjectBase>(path);
if (!asset)
{
asset = CreateInstance(type) as ScriptableObjectBase;
UnityEditor.AssetDatabase.CreateAsset(asset, path);
UnityEditor.AssetDatabase.SaveAssets();
UnityEditor.AssetDatabase.Refresh();
}
return asset;
#endif
throw new NotImplementedException();
}
}
public abstract class ScriptableObjectBase<T> : ScriptableObjectBase where T : ScriptableObjectBase<T>
{
public static string AssetPath => GetAssetPath(typeof(T).Name);
public static T Instance => GetScriptableObject(typeof(T)) as T;
}
}
그렇게해서 만든 것이 ScriptableObjectBase이다. 빌드 후에는 불러오는 방법을 수정해야 하는데, 일단 에디터에서만 돌릴 예정이라 에디터 구현만 신경썼고, 추후에 문제되는 부분을 명확히 알 수 있도록 에디터가 아닌 곳에서 호출하면 NotImplementedException을 throw하도록 해놓았다.
@ -0,0 +1,50 @@
using System;
using System.Linq;
using Script.ClientLib;
using UnityEditor;
using UnityEngine;
namespace Script.EditorLib.Tools
{
public class ScriptableObjectWindow : EditorWindow
{
private Type[] _types;
[MenuItem("Tools/Find ScriptableObject &S")]
public static void FindScriptableObject()
{
GetWindow<ScriptableObjectWindow>().Show();
}
private void OnEnable()
{
_types = TypeCache.GetTypesDerivedFrom<ScriptableObjectBase>()
.Where(t => !t.IsAbstract && !t.IsGenericTypeDefinition)
.OrderBy(t => t.Name)
.ToArray();
}
private void OnGUI()
{
if (_types == null)
return;
foreach (var t in _types)
{
if (GUILayout.Button(t.Name))
{
SelectScriptableObject(t);
}
}
}
private void SelectScriptableObject(Type type)
{
var asset = ScriptableObjectBase.GetScriptableObject(type);
Selection.activeObject = asset;
EditorGUIUtility.PingObject(asset);
Close();
}
}
}
또한, ALT+S를 눌러 현재 구현된 ScriptableObjectBase의 목록을 보여주고, 선택하면 해당 오브젝트를 선택하거나, 존재하지 않는 경우 생성하여 편리하게 사용할 수 있도록 작업을 해놨다.
using System;
using System.Collections.Generic;
namespace Script.ClientLib
{
public class ModelPathSettings : ScriptableObjectBase<ModelPathSettings>
{
[Serializable]
public class ModelPathData
{
public string entityName;
public string modelPath;
}
public List<ModelPathData> modelPaths;
public string GetModelPath(string entityName)
{
// TODO: 성능 개선, 비슷한 방식이 자주 사용될 것 같아 기반을 다져놓으면 좋을 것 같음 (예를 들면 ModelPathData를 기반으로 entityName을 key로 가지는 Dictionary로 자동 변환하여 찾아올 수 있는 자료 구조를 만든다든가)
return modelPaths.Find(e => e.entityName == entityName)?.modelPath;
}
}
}
이후 만든 것이 ModelPathSettings이다. 간단하게 entityName에 따라 사용할 모델의 경로를 찾을 수 있도록 해놨다. 성능적으로 크게 이슈가 되지는 않을 것 같아 일단 Linq로 간단하게 찾아오는 방식을 사용했다. 비슷한 방식으로 ScriptableObject를 많이 사용할 것 같아 효율적이고 사용하기 편한 구조를 다져놓으면 좋을 것 같은데, 현재로서는 우선순위가 높지 않다고 판단하여 일단 간단하게 구현했다.
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Script.CommonLib;
public class Vector3Converter : JsonConverter<Vector3>
{
public override void WriteJson(JsonWriter writer, Vector3 value, JsonSerializer serializer)
{
writer.WriteStartObject();
writer.WritePropertyName("x"); writer.WriteValue(value.x);
writer.WritePropertyName("y"); writer.WriteValue(value.y);
writer.WritePropertyName("z"); writer.WriteValue(value.z);
writer.WriteEndObject();
}
public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 existingValue, bool hasExistingValue,
JsonSerializer serializer)
{
var obj = JObject.Load(reader);
return new Vector3(
(float)obj["x"],
(float)obj["y"],
(float)obj["z"]
);
}
}
using System;
using Newtonsoft.Json;
namespace Script.CommonLib
{
public static class JsonSerialize
{
private static readonly JsonSerializerSettings settings = new()
{
Converters = { new Vector3Converter() },
};
public static string SerializeToJson(this object obj)
{
var json = JsonConvert.SerializeObject(obj, settings);
return json;
}
public static T DeserializeObject<T>(string str)
{
var obj = JsonConvert.DeserializeObject<T>(str, settings);
return obj;
}
}
}
이전에 만들어놓은 Vector3를 Serialize할 때, normalize에서 Recursive한 호출이 일어나서 x, y, z만 Serialize할 수 있도록 JsonSerializerSettings를 추가하였다.
Vector3.normalize에 [JsonIgnore] Attribute를 추가할 수도 있었으나, 기반 코드에 종속성이 들어가는 게 상당히 꺼림칙하여 이런 방법을 택했다.
다만 이 방법의 또 다른 문제는... 누군가 이것의 존재를 모르고 JsonConvert를 사용했을 경우이다. 근데 어쨌든 공통적으로 사용할 JsonSerializeSettings는 필요해질 가능성이 높기 때문이 이 방식을 택했다.
그리고 GridPos의 X, Y가 원래는 readonly였는데, readonly를 제거해주었다. Deserialize하는 방식이 오브젝트를 만든 뒤에 값을 세팅하는 방식이었던 것 같은데, 그것 때문인지 Deserialize 후에 값이 제대로 세팅되지 않아서 수정해주었다.
using System;
namespace Script.CommonLib
{
[Serializable]
public class EntityData
{
// TODO: 추후에 더 좋은 방법으로 수정하면 좋을 것 같음. (예: startPosition, endPosition을 이름으로 찾아오고 있는 부분)
public string name;
public string startPositionName;
public string endPositionName;
public Vector3 modelScale = new Vector3(3.5f, 3.5f, 3.5f);
}
}
using Script.CommonLib;
using UnityEngine;
namespace Script.ClientLib
{
public class EntityComponent : MonoBehaviour
{
public EntityData entityData;
}
}
이후에는 Entity 데이터를 에디터에서 세팅할 수 있도록, EntityData와 EntityComponent를 추가했다.
이름을 토대로 모델과 연결하고, 시작 지점과 종료 지점도 일단 이름으로 찾아오도록 작업을 해놨다. 뭔가 더 좋은 방법이 있을 것 같은데, 개선할 방법이 바로 떠오르지 않는데다가 아마 시작점 / 종료점이 많아지지는 않을 거라 현 상태로도 괜찮지 않을까 싶다. 추후에 여유가 생기면 작업할 것 같다.
그리고 기존 코드에서 Scene을 저장할 때 MapData를 저장하도록 했는데, EntityData도 같이 저장하도록 작업해놓았다. 장기적으로는 캐릭터의 무기나 수치 등을 세팅할 것이기 때문에 이런 것들을 아우를 수 있는 뭔가가 필요하지 않을까 싶은데, 추후에 수정해도 되는 부분이라 일단 간단하게 작업해놨다. 당장 필요하지 않은 것에 너무 매몰되다보면 진행이 더뎌지기 때문이다. (다만 무책임하게 작업해놓는 것도 문제라, 추후에라도 수정하고 싶은 부분은 TODO를 달아놓고 있다)
namespace Script.CommonLib.Map
{
public interface IBattleMapEventHandler
{
public void OnEntityAdded(Entity entity);
public void OnEntityPositionChanged(Entity entity, Vector3 pos);
public void OnEntityDirectionChanged(Entity entity, Vector3 pos);
}
}
using System.Collections.Generic;
using System.IO;
using Script.CommonLib;
using Script.CommonLib.Map;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
using Vector3 = Script.CommonLib.Vector3;
namespace Script.ClientLib
{
public class ClientBattleMapSimulator : MonoBehaviour, IBattleMapEventHandler
{
private BattleMapSimulator _battleMapSimulator;
private Dictionary<Entity, GameObject> _models = new();
private void Start()
{
var scene = SceneManager.GetActiveScene();
var path = $"Assets/Data/MapData/{scene.name}_Data.json";
var json = File.ReadAllText(path);
if (string.IsNullOrEmpty(json))
{
LogHelper.Error($"file {path} not found");
return;
}
var battleMapData = JsonSerialize.DeserializeObject<BattleMapData>(json);
if (battleMapData == null)
{
LogHelper.Error($"file {path} is not a BattleMapData");
return;
}
_battleMapSimulator = new BattleMapSimulator(this, battleMapData);
_battleMapSimulator.Init();
}
private void Update()
{
_battleMapSimulator.Update(Time.deltaTime);
}
public void OnEntityAdded(Entity entity)
{
var modelPath = ModelPathSettings.Instance.GetModelPath(entity.name);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(modelPath);
var obj = Instantiate(prefab);
obj.transform.localScale = new UnityEngine.Vector3(entity.modelScale.x, entity.modelScale.y, entity.modelScale.z);
_models[entity] = obj;
}
public void OnEntityPositionChanged(Entity entity, Vector3 pos)
{
// TODO: 새로운 class를 만들어서 처리해야 함.
_models.TryGetValue(entity, out var obj);
if (!obj)
return;
LogHelper.Log($"OnEntityPositionChanged: {entity.name} {pos}");
obj.transform.position = new UnityEngine.Vector3(pos.x, pos.y, pos.z);
}
public void OnEntityDirectionChanged(Entity entity, Vector3 dir)
{
_models.TryGetValue(entity, out var obj);
if (!obj)
return;
obj.transform.rotation = Quaternion.LookRotation(new UnityEngine.Vector3(dir.x, dir.y, dir.z));
}
}
}
using System.Collections.Generic;
namespace Script.CommonLib.Map
{
public class BattleMapSimulator : IBattleMapEventHandler
{
public BattleMapSimulator(IBattleMapEventHandler battleMapEventHandler, BattleMapData battleMapData)
{
_battleMapEventHandler = battleMapEventHandler;
_battleMapData = battleMapData;
_battleMapPathFinder = new BattleMapPathFinder(battleMapData);
}
private readonly IBattleMapEventHandler _battleMapEventHandler;
private readonly BattleMapData _battleMapData;
private readonly BattleMapPathFinder _battleMapPathFinder;
private readonly List<Entity> _entities = new();
public void Init()
{
foreach (var entityData in _battleMapData.entities)
{
var entity = new Entity(this, _battleMapPathFinder, entityData);
_entities.Add(entity);
OnEntityAdded(entity);
}
}
public void Update(float deltaTime)
{
foreach (var entity in _entities)
{
entity.Update(deltaTime);
}
}
public void OnEntityAdded(Entity entity)
{
_battleMapEventHandler.OnEntityAdded(entity);
var startPositionData = _battleMapData.GetBattlePositionDataByName(entity.startPositionName);
var endPositionData = _battleMapData.GetBattlePositionDataByName(entity.endPositionName);
if (startPositionData != null)
entity.SetPos(startPositionData.gridPos);
if (endPositionData != null)
entity.MoveTo(new Vector3(endPositionData.gridPos.x, 0, endPositionData.gridPos.y));
}
public void OnEntityPositionChanged(Entity entity, Vector3 pos)
{
_battleMapEventHandler.OnEntityPositionChanged(entity, pos);
}
public void OnEntityDirectionChanged(Entity entity, Vector3 pos)
{
_battleMapEventHandler.OnEntityDirectionChanged(entity, pos);
}
}
}
오늘 작업한 부분에서 가장 핵심적인 부분이 아닐까 한다. BattleMapSimulator는 클라/서버 공용으로 돌아가며, 맵 하나를 관리해주는 class이다. 다만, 클라/서버 각각이 따로 이 class를 만들고 관리해주어야 한다. 클라이언트에서 먼저 이 처리를 해주기 위해 만든 것이 바로 ClientBattleMapSimulator이다.
우선 매우 간단하게 Scene에 넣어놓으면 Start에서 현재 Scene의 이름을 토대로 데이터를 불러오고, 해당 데이터로 BattleMapSimulator를 만든다. 이후 Update를 전달하는 식으로 동작한다.
핵심적인 로직은 BattleMapSimulator에서 처리된다. 예를 들어 길찾기가 그러하다. 근데 길찾기를 통해 Entity가 움직이면 그에 해당하는 모델을 로드하고 움직이는 부분을 클라이언트에서 처리해야 한다. 이런 처리를 위해 추가한 interface가 IBattleMapEventHandler이다. Entity가 움직이거나 회전할 때도 Event를 발생시켜야 하는데, BattleMapSimulator 또한 IBattleMapEventHandler를 상속받도록 하여 Entity가 BattleMapSimulator에 간섭할 걱정 없도록 하였다.
현재까지의 흐름을 간단히 요약해보자면 아래와 같다.
맵 데이터 세팅: 에디터에서 Scene에 세팅 후 저장하면 json 파일로 저장
게임 시작 후: ClientBattleMapSimulator가 저장된 맵 데이터 json 파일을 불러와 BattleMapSimulator를 초기화함.
게임 진행 중: ClientBattleMapSimulator의 Update를 전달하여 BattleMapSimulator에서 핵심 코드가 돌아가고, 모델 처리 등 클라/서버 각각에 필요한 구현은 IBattleMapEventHandler를 통해서 이벤트를 전달 및 각각 별도로 처리.
ClientBattleMapSimulator에서 Dictionary<Entity, GameObject>로 처리를 한 부분이 다소 허접하긴 하나... 내일 애니메이션 처리를 해야 하기 때문에 해당 내용을 먼저 고민한 후에 처리하기로 하였다. 지금 드는 생각은 "어떤" class를 추가해서 이런 모델 로드, 이동, 회전, 더 나아가 이펙트 처리 등등을 담당하도록 하는 것이다. 아마 그 안에 애니메이션 처리를 담당할 Component를 만들지 않을까 싶다. 그리고 거기에 "이동 중", " 공격 중" 등의 상태를 활성화하고, 우선 순위에 따라 애니메이션을 전환하도록 하지 않을까 싶다. 다만 Animator를 어떻게 활용할지가 관건이라, 실은 Animator를 사용해본 지가 조금 되어서... 이전 개인 프로젝트에서 구현했던 내용을 참조한 뒤에 결정을 하게 될 것 같다. class 이름도 약간 고민인데, 지금은 Entity라고 하는 게 사실 캐릭터인 것 같지만, 실제로는 보급품이나, 장애물 or 엄폐물 등도 Entity에 포함될 거라서... "움직이거나 공격을 하는 등 전투를 하는 객체"는 따로 Entity를 상속받은 Character라고 하는 class로 만들 것을 염두에 두고 있다. 그럼 클라이언트 처리를 담당하는 class 이름을 뭐로 지으면 좋을까가 아직까지도 고민이긴 한데... 내일 조금 더 고민하고 결정해봐야겠다.
아무튼 오늘 작업한 내용은 아래와 같다.
어째 이전보다 허접해진 것 같다는 생각이 드는데 -_-... 그도 그럴 것이 눈에 안 보이는 부분만 바뀌었기 때문이다. 나한테는 사실 여기까지 한 것 만으로도 전체 틀은 완성됐고, "앞으로 계획한 기능들을 전부 구현할 수 있다"는 가시성이 보이기 때문에 기분이 꽤나 좋다.
실은 오늘 잠을 제대로 못자서 아침에 두통과 함께 컨디션이 정말 안 좋았고, 그런 와중에 구현할 내용도 많아서 '내일까지 할까...' 싶은 생각도 들었는데, 어찌저찌 정신 붙잡고 하다보니까 계획한 부분까지 얼추 마무리되어서 기분이 더 좋은 것 같다. 이전 경험을 토대로 집에만 있는 것 보다는 밖에 나가는 게 더 도움이 된다는 걸 알았기 때문에 오후에 졸릴 때쯤 되면 뒷산에 다녀오고 있다. 대략 4km 조금 넘는 둘레길을 걷고 오는데, 몸이 힘들어서 잡생각도 없어지고, 간간히 설계 관련 생각이 들 때 거기에만 집중할 수 있어서 확실히 집에 있을 때보다 더 도움이 되는 것 같다. 체력도 더 좋아지는 것 같고... 피곤해서 밤에 잠도 잘 오고...
지금 계획으로는 12시 취침, 7~8시 기상, 뇌를 잠시 깨운 후 그날 할 일 상세히 계획하기, 동기부여 하기, 실행에 옮기기, 졸릴 때 쯤 산행 다녀오기, 남은 작업 마무리하기, 정리해서 블로그에 올리기, 코딩 테스트 풀기, 자유 시간 갖기. 이 정도의 루틴을 생각하고 있다. 하루 작업이 빨리 끝난 날은, 코딩 테스트 문제를 미리 머리에 몇 개 넣어 산행에 가는 것도 좋을 것 같다. (어제 그렇게 1문제를 머리로 풀었는데 2번째 문제를 머리에 안 넣어가서 1문제에서 마무리됐다)
첫 날 시작이 좋은 것 같다. 내일과 이후 일정도 착실히 잘 달성해봐야겠다. 아자아자 화이팅~~~~
'개발 > 개발일지' 카테고리의 다른 글
| Tactical Combat System 개발일지 5 - 진영 구분 추가, 적군 추가, 근처의 적군 탐색 기능 추가 (1) | 2026.02.06 |
|---|---|
| Tactical Combat System 개발일지 4 - 이동할 때 움직이는 애니메이션, 정지하면 Idle 애니메이션 재생 (0) | 2026.02.05 |
| Tactical Combat System 개발일지 2 - 단기 목표에 대한 계획과 일정 수립 (0) | 2026.02.03 |
| Tactical Combat System 개발일지 1 - 기존 진행 상황 상세 정리 (0) | 2026.02.02 |
| Tactical Combat System 개발일지 0 - 목표 설정, 기존 진행 상황 요약 (0) | 2026.02.02 |