Editor 작업
Editor 작업

Editor 작업

Tags
C#
Unity
Editor
Java
.Net
MySql
Socket
Published
Author
Description
URL

무엇을 만들었는가?

일단 소개할 핵심 시스템은 2가지이다. 1. 로그인 인증 → TCP 소켓 통신
2. 캐릭터 제네레이터 간단히 소개하자면, 로그인 인증부터 소켓 접속까지의 과정과
수백 개의 Sprite 이미지 애니메이션을 노가다 없이 자동화하여 만들어주는 Editor Tool을 만든 내용을 담고 있다.
 

영상으로 확인해보자

 


 
 

어떻게 만들었는가?

각각 어떻게 만들었는지에 대한 소스코드 설명과 제작 과정을 차차 포스팅할 예정이다.
 

캐릭터 제네레이터

MSCA 와 시행착오

처음엔 MSCA를 사용하려 하였다.
MSCA란? - manaseedcharacteranimation
manaseed 라는 2D Character 리소스 팩이다.
근데 문제점이라고 한다면..
notion image
그 소스 파일이 어마 무시하게 많다 이렇게 되면 하나하나 애니메이션 컨트롤러는 물론, Clip등을 만들어 줘야 하는데
 
당연하게도 엄청난 노가다 작업과 캐릭터가 교체되면 Body 부터 시작해서 각 파츠별로 애니메이션 컨트롤러를 만들어줄 순 없는 노릇이기에 이걸 자동화 해주는 서브파티라 보면 된다.
 
원리는 다음과 같다.
 
  1. 일단 모든 Texture들은 동일한 x,y 좌표 값에 동일 한 애니메이션이 들어가 있다. 이것을 이용해서
이미지를 Cell Size 만큼 잘라낸 빈 Textrue 파일을 만든다.
notion image
  1. 리소스의 이름을 기준으로 폴더를 나누고, 빈 텍스쳐를 파츠별로 만들어둔다.
notion image
  1. 각각의 키 프레임과, animation에 사용 될 아까 잘라낸 Texture 파일의 index 번호를 할당한다
  1. animation Controller는 해당 빈 텍스쳐 파일을 기준으로 Clip들을 만들어낸다.
  1. 이후 해당 텍스쳐의 Pixels를 받아와서 그대로 파츠/애니메이션 별로 빈 텍스쳐에다 갖다 붙여준다
private void FillPlayerTexture(string layer, Texture2D pTexture, string key) { if (SpriteSetPath.EndsWith("/")) SpriteSetPath = SpriteSetPath.TrimEnd('/'); Texture2D originp1 = Resources.Load<Texture2D>(SpriteSetPath + "/" + key + "/" + layer); if (pTexture != null && originp1 != null) { Color[] newPixelsp1 = pTexture.GetPixels(); originp1.SetPixels(newPixelsp1); originp1.Apply(); } } private static Texture2D SetTexture(string fileBasePath, Dictionary<string, string> textPaths, string textureKey, bool combatAnimation) { if (!fileBasePath.EndsWith("/")) fileBasePath += "/"; Texture2D pTexture = null; if (textPaths[textureKey] != "") { pTexture = Resources.Load<Texture2D>(fileBasePath + textPaths[textureKey].Replace(".png", "")); if (combatAnimation) if (pTexture == null) pTexture = Resources.Load<Texture2D>(fileBasePath + "combat/" + textPaths[textureKey].Replace(".png", "")); //Debug.Log(fileBasePath + "combat/" + textPaths[textureKey].Replace(".png", "")); } return pTexture; }
 
간략하게 돌아가는 구조를 정리해보자면
빈 텍스쳐 파일을 만듬 → 원본 텍스쳐의 pixcel을 가져다 붙임 방식이다.
 
확실히 잘 굴러가지만 치명적인 문제점이 있다.
새로운 캐릭터가 만들어질려면 , 해당 빈 텍스쳐 파일을 또 만들어 줘야 한다는 건데
Character 1 : Texture N + Animation Controller 1 + Animation Clip N 개가 필요하다.
 
이건 뭔가 이상해서 혹시 내가 잘못 이해하고 분석했나 하여, 개발자에게 직접 문의해보았다.
 
notion image
심지어 해당 에셋을 만든 개발자도 그렇게 하란다.
notion image
Meta 파일까지 포함한 한 플레이어당 6,263개 파일을 만들어서 사용하라고?
이게 싱글 플레이 기준이라면 뭐 그러려니 할 수 있겠지만, 하고자 하는 건 멀티 플레이 환경이고
이래선 써먹을 수 가 없었다…
누군가 같은 고민을하고 있어, 해당 오픈소스 프로젝트를 받았다.
대략, 해당 프로젝트의 구조와 장점은 대강 아래와 같다.
 
장점1.
자 우린 애니메이션 컨트롤러 하나 만 사용한다!
어떻게?
notion image
BodyPartsManager라는 Script를 통해서 그게 가능하다.
notion image
notion image
일단 베이스가 될 수 있는 걸 하나 만들어둔다 또한, Layers와 블렌드 트리를 사용해서
기존에 파츠별로 애니메이션 컨트롤러와 연결 노가다를 안해도 된다!
 
( 기존 방식 아래 난관 1 형태 )
notion image
notion image
문제는 해당 프로젝트 역시, 각 리소스 별로 클립을 만들어 두었다는 것이 었다.
근데 그럼 어떻게 교체하였는지는 아래 코드를 통해 알 수 있었다.
private void Start() { // Set animator animator = GetComponent<Animator>(); animatorOverrideController = new AnimatorOverrideController(animator.runtimeAnimatorController); animator.runtimeAnimatorController = animatorOverrideController; defaultAnimationClips = new AnimationClipOverrides(animatorOverrideController.overridesCount); animatorOverrideController.GetOverrides(defaultAnimationClips); // Set body part animations UpdateBodyParts(); } public void UpdateBodyParts() { // Override default animation clips with character body parts for (int partIndex = 0; partIndex < bodyPartTypes.Length; partIndex++) { // Get current body part string partType = bodyPartTypes[partIndex]; // Get current body part ID string partID = characterBody.characterBodyParts[partIndex].bodyPart.bodyPartAnimationID.ToString(); for (int stateIndex = 0; stateIndex < characterStates.Length; stateIndex++) { string state = characterStates[stateIndex]; for (int directionIndex = 0; directionIndex < characterDirections.Length; directionIndex++) { string direction = characterDirections[directionIndex]; // Get players animation from player body // ***NOTE: Unless Changed Here, Animation Naming Must Be: "[Type]_[Index]_[state]_[direction]" (Ex. Body_0_idle_down) animationClip = Resources.Load<AnimationClip>("Player Animations/" + partType + "/" + partType + "_" + partID + "_" + state + "_" + direction); // Override default animation defaultAnimationClips[partType + "_" + 0 + "_" + state + "_" + direction] = animationClip; } } } // Apply updated animations animatorOverrideController.ApplyOverrides(defaultAnimationClips); }
애니메이터 컨트롤러를 시작할 때 새로 하나 할당하고, 해당 컨트롤러 블렌드트리에 들어가 있는
Clip 데이터만 교체한다.
 
notion image
그 클립데이터는 Serialize Object를 통해 넣어주면 되었다.
 
확실히 해당 방법은 훨씬 깔끔하고, 멀티 환경에서도 충분히 구동 될 만 했다.
그러니깐.. 파츠별로 하나하나 클립 노가다 하면 되긴 한다..!
 
하지만 그런 노가다를 줄일 수 있는 방법은 없을까? 우선 1차 난관이었던, MSCA에서도 눈 여겨 볼 만한 점이 툴을 사용해서 노가다를 줄였다는 거다.
 
해당 방안을 착안해서 툴을 만들어볼려고 하였다 .
 

결론 및 제작법

일단 다시 한번, 문제를 복기 해보면,
notion image
notion image
위 이미지와 같이, 색상만 다르고 똑같은 이미지가 반복된다는 점이었다.
이것을 하나하나 애니메이션을 넣어주기엔 너무나도 반복적인 작업과 유지보수 측면에서
매우 어렵다는 것이 문제 사항이었다.
 
  1. SpriteLibraryAsset
유니티에서 공식으로 지원하는 패키지였다. 여러 샘플 자료와 매뉴얼을 보면서 쓸 만한 기능을
발견하였다.
notion image
저기에 교체할 스프라이트 리스트를 넣어줄 수 있었는데. 여기에 에니메이션을 넣으면 어떨까 하는 생각이 들었다.
 
notion image
여기서 핵심이 되는 것은, 각 카테고리로 애니메이션을 분류할 수 있고 태그를 달아 줄 수 있었다.
또 Main Library라고 부모가 될 에셋을 결정해줄 수 있었는데 나중에 해당 에셋을 참고하여. 거의 비슷한 형태로 만들 수 가 있게 된다.
notion image
  1. CreateCharacterEditor작성.
using UnityEngine; using UnityEditor; using System; using UnityEditor.Animations; using System.Collections.Generic; using System.IO; using UnityEngine.U2D.Animation; using System.Linq; using static Define; namespace Assets.Scripts.Character_Creator.Editor { public class CreateCharacterEditor : EditorWindow { [SerializeField] SO_CreateCharacter _soBase; [SerializeField] Texture2D _baseTexture; [SerializeField] Parts _parts; SpriteLibraryAsset _baseSpriteAsset; [MenuItem("Tools/Create Charater Skin")] public static void CCSEdiorEditorWindow() { GetWindow<CreateCharacterEditor>("Create Character Skin!"); } private void OnGUI() { GUILayout.Label("Create Charater Skin!!"); _soBase = (SO_CreateCharacter)EditorGUILayout.ObjectField( "BaseSkin Sprite Library Settings", _soBase, typeof(SO_CreateCharacter), false); _baseTexture = (Texture2D)EditorGUILayout.ObjectField( "BaseSkin Texture2D Settings", _baseTexture, typeof(Texture2D), false); _parts = (Parts)EditorGUILayout.EnumPopup("Select Parts", _parts); if (GUILayout.Button("Create Charater!")) { _baseSpriteAsset = _soBase.MainAsset; CreateCharater(_baseSpriteAsset); } } private void test(SpriteLibraryAsset asset , Texture2D texture,Parts parts) { //SpriteLibraryAsset newAsset = CreateInstance<SpriteLibraryAsset>(); //Dictionary<string, Sprite> copylables = new Dictionary<string, Sprite>(); //foreach (var categorys in asset.GetCategoryNames()) //{ // foreach(var labels in asset.GetCategoryLabelNames(categorys)) // { // Sprite originalSprite = asset.GetSprite(categorys, labels); // Sprite newSprite = Sprite.Create(texture, originalSprite.rect, originalSprite.pivot, originalSprite.pixelsPerUnit, 0, SpriteMeshType.FullRect, originalSprite.border); // // Add the new sprite to the new asset // newAsset.AddCategoryLabel(newSprite,categorys, labels); // } //} //string assetPath = "Assets/NewSpriteLibraryAsset.spriteLib"; //AssetDatabase.CreateAsset(newAsset, assetPath); //AssetDatabase.SaveAssets(); //AssetDatabase.Refresh(); //SerializedObject serializedAsset = new SerializedObject(asset); //SerializedProperty categoriesProperty = serializedAsset.FindProperty("m_Labels"); //for (int i = 0; i < categoriesProperty.arraySize; i++) //{ // SerializedProperty categoryProperty = categoriesProperty.GetArrayElementAtIndex(i); // SerializedProperty labelCategories = categoryProperty.FindPropertyRelative("m_CategoryList"); // for (int j = 0; j < labelCategories.arraySize; j++) // { // //SerializedProperty labelProperty = labelCategories.GetArrayElementAtIndex(j); // //SerializedProperty spriteProperty = labelProperty.FindPropertyRelative("m_Sprite"); // //Sprite originalSprite = (Sprite)labelProperty.FindPropertyRelative("m_Sprite").objectReferenceValue; // SerializedProperty labelProperty = labelCategories.GetArrayElementAtIndex(j); // Sprite originalSprite = (Sprite)labelProperty.FindPropertyRelative("m_Sprite").objectReferenceValue; // // Create a new sprite with the new texture // Sprite newSprite = Sprite.Create(texture, originalSprite.rect, originalSprite.pivot, originalSprite.pixelsPerUnit, 0, SpriteMeshType.FullRect, originalSprite.border); // // Set the new sprite to the serialized property // labelProperty.objectReferenceValue = newSprite; // } //} //// Apply the modifications to the serialized asset //serializedAsset.ApplyModifiedPropertiesWithoutUndo(); //// Save the modified asset //EditorUtility.SetDirty(asset); //AssetDatabase.SaveAssets(); //AssetDatabase.Refresh(); SerializedObject serializedAsset = new SerializedObject(asset); SerializedProperty categoriesProperty = serializedAsset.FindProperty("m_Labels"); for (int i = 0; i < categoriesProperty.arraySize; i++) { SerializedProperty categoryProperty = categoriesProperty.GetArrayElementAtIndex(i); SerializedProperty labelCategories = categoryProperty.FindPropertyRelative("m_CategoryList"); for (int j = 0; j < labelCategories.arraySize; j++) { SerializedProperty labelProperty = labelCategories.GetArrayElementAtIndex(j); Sprite m_Sprite = (Sprite)labelProperty.FindPropertyRelative("m_Sprite").objectReferenceValue; } // Do something with the category name } Debug.Log("SpriteLibraryAsset modified and saved successfully."); } private void CreateCharater(SpriteLibraryAsset asset) { SpriteLibraryAsset newAsset = asset; var savedAsset = SpriteLibraryAssetHelper.SaveAsSpriteLibrarySourceAsset (newAsset, "Assets/@Resources/Sprite Library/Player/" + _parts.ToString() + "/" + _baseTexture.name + ".spriteLib", _baseTexture.name,_parts.ToString(),asset,_soBase); EditorUtility.SetDirty(savedAsset); } } }
기능 설명 이전에 결과물을 먼저 확인해보자.
notion image
에디터의 GUI 는 상당히 간단하다. Base가 될 Base Body를 넣어주고, 새로 만들 Texture를 넣어준다 예를 들어, Base 스킨이 하얀색이라면, 새로 만들 Skin이 파란색이라면 파란색 sprite를 넣어주면 된다.
Select Parts는 선택한 Skin이 무엇 인지를 알려줘야 한다. 현재로서는 Body 밖에 없지만 차후 모자라던지, 상의,하의라던지 여러 파츠들을 선택 할 수 있다.
 
우선 test 메소드는 굳이 볼 필요는 없지만, 같이 올린 이유는
notion image
처음엔 이렇게 texture로 접근해서 정보를 가져올려 했었다. 문제는 접근성 문제로 가져올 수 없었고 텍스쳐의 이름만 바꿔준다던가, 위치좌표를 알아내어서 뭔가 할 수 있지 않을까라는 생각이 있었기 때문인데. 결국엔 불러오진 못했고.
여러 실패와 서칭을 통해 해결 방법을 찾아내어 CreateCharater 메소드를 만들 수 있었다.
저기서 핵심적인 부분은 SpriteLibraryAssetHelper.SaveAsSpriteLibrarySourceAsset 인데. Sprite Libarary Asset을 만들 순 있었지만 어째서인지 다른 형태로 나오게 되었고 여러 서칭 결과
 
notion image
공식적으로 지원하는 라이브러리에서조차 없어 Unity 개발팀에서 비정식 소스코드를 공유해주었고.
public static SpriteLibraryAsset SaveAsSpriteLibrarySourceAsset(SpriteLibraryAsset asset, string path, string loadName, string partName, SpriteLibraryAsset mainAsset = null,SO_CreateCharacter baseCharacter = null) { if (asset == null || string.IsNullOrEmpty(path) || !path.StartsWith("Assets/") || Path.GetExtension(path) != assetExtension) return null; var sourceAsset = ScriptableObject.CreateInstance(SpriteLibraryTypes.sourceAssetType); var categoryListType = typeof(List<>).MakeGenericType(SpriteLibraryTypes.spriteLibCategoryOverrideType); var categoryList = Activator.CreateInstance(categoryListType); var categoryAddMethod = categoryListType.GetMethod("Add"); Debug.Assert(categoryAddMethod != null); var labelListType = typeof(List<>).MakeGenericType(SpriteLibraryTypes.labelOverrideEntryType); var labelAddMethod = labelListType.GetMethod("Add"); Debug.Assert(labelAddMethod != null); Sprite[] sprites = Resources.LoadAll<Sprite>("Sprites/Player/"+ partName+"/"+ loadName); var categories = mainAsset.GetCategoryNames(); if (categories != null) { foreach (var category in categories) { var labelList = Activator.CreateInstance(labelListType); var labels = asset.GetCategoryLabelNames(category); if (labels != null) { foreach (var label in labels) { var sprite = asset.GetSprite(category, label); string[] sprite_split = sprite.name.Split("_"); int idx = int.Parse(sprite_split[sprite_split.Length-1]); var spriteLibLabel = Activator.CreateInstance(SpriteLibraryTypes.labelOverrideEntryType); SpriteLibraryFields.spriteOverrideField.SetValue(spriteLibLabel, sprites[idx]); SpriteLibraryFields.labelNameField.SetValue(spriteLibLabel, label); labelAddMethod.Invoke(labelList, new[] { spriteLibLabel }); } } var spriteLibCategory = Activator.CreateInstance(SpriteLibraryTypes.spriteLibCategoryOverrideType); SpriteLibraryFields.categoryNameField.SetValue(spriteLibCategory, category); SpriteLibraryFields.overrideEntriesField.SetValue(spriteLibCategory, labelList); categoryAddMethod.Invoke(categoryList, new[] { spriteLibCategory }); } } SpriteLibraryMethods.setLibraryMethod.Invoke(sourceAsset, new[] { categoryList }); var mainAssetGuid = mainAsset != null ? AssetDatabase.GUIDFromAssetPath(AssetDatabase.GetAssetPath(mainAsset)).ToString() : string.Empty; SpriteLibraryFields.mainAssetGuidField.SetValue(sourceAsset, mainAssetGuid); SpriteLibraryMethods.saveSpriteLibrarySourceAssetMethod.Invoke(null, new object[] { sourceAsset, path }); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); baseCharacter.ChildUpdate(asset, path, loadName, partName); return AssetDatabase.LoadAssetAtPath<SpriteLibraryAsset>(path); }
경로라던지 일부분을 커스텀하게 변경하여 제작하였다. 저 소스코드의 핵심은 간단한데, 일단 Base에서 만들어진 Category를 모두 불러오고 선택한 이름의 Sprite를 모두 불러온다.
notion image
이후 categories의 크기만큼 반복문을 돌리며, 새로 만들 SPA를 만들어준다는 내용이다.
notion image
결론적으로 위 사진과 같이 버튼 하나로 잘라진 png를 바탕으로 해당 sprite Library Asset을 얻어낼 수 있다는 것이다.
  1. ScriptableObject 작성.
using System; using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; using UnityEngine.U2D.Animation; using static Define; [CreateAssetMenu(fileName = "SO_Base_", menuName = "Scriptable Objects/Player Parts Base")] public class SO_CreateCharacter : ScriptableObject { [SerializeField] SpriteLibraryAsset mainAsset; public SpriteLibraryAsset MainAsset { get { return mainAsset; } } public List<ChildAsset> ChildAssets = new List<ChildAsset>(); [System.Serializable] public class ChildAsset { public SpriteLibraryAsset asset; public string path; public Parts part; public string name; } public void ChildUpdate(SpriteLibraryAsset asset,string path,string name,string partName) { var child = ChildAssets.FirstOrDefault(pred => pred.name == name); if (child == null) { child = new ChildAsset { path = path, asset = asset, name = name, part = Enum.Parse<Parts>(partName) }; ChildAssets.Add(child); } } public void AllUpdate() { for (int i = 0; i < ChildAssets.Count; i++) { var save = SpriteLibraryAssetHelper.SaveAsSpriteLibrarySourceAsset(ChildAssets[i].asset,ChildAssets[i].path, ChildAssets[i].name,ChildAssets[i].part.ToString() ,mainAsset,this); EditorUtility.SetDirty(save); } } }
  1. 베이스가 될 애니메이션을 만들어준다.
notion image
우선은 베이스가될 애니메이션을 블렌드 트리를 통해 만들어준다.
notion image
또한, 애니메이터나 Clips를 보면 딱 Base가 될 것만 하나만 존재하는데
notion image
그게 가능한 이유가 Sprite Libarary Asset에 포함된 Resolver 덕분인데. Animation을 보면 키 프레임을 정해주면 해당 카테고리의 해당 Sprite로 보일 수 있게 된다.
notion image
즉. 이제 Sprite Library Component에 Sprite Library Asset을 매크로로 만들어둔, SLA 파일을 바꿔주는 것 만으로도 모든 애니메이션을 번거롭게 하나하나 노가다하여 만들 필요없이 자동으로 바꿔준다.
notion image
또 Create Character Skin을 통해 만든 건 자동으로 SO_Base_Body에 항목이 추가되는데.
아까 SpriteLibraryAsset SaveAsSpriteLibrarySourceAsset를 커스텀하게 수정했던 부분 중 baseCharacter.ChildUpdate(asset, path, loadName, partName); 를 통해 자동으로 추가할 수 있다.
notion image
notion image
다만 위와 같이 새로운 애니메이션을 추가하게 될 경우 자동으로 SLA 들은 베이스를 따라가게 되는데, 이 때에는
notion image
Base_Body에서 Update 버튼을 누름으로써 자동으로 Child Asset들의 Sprite를
변경해줄 수 있도록 하였다.
using UnityEditor; using UnityEngine; namespace Assets.Scripts { [CustomEditor(typeof(SO_CreateCharacter))] class SOEdit : Editor { public override void OnInspectorGUI() { base.OnInspectorGUI(); var script = (SO_CreateCharacter)target; if(GUILayout.Button("Update")) { script.AllUpdate(); } } } }
notion image
 

마치며

해당 내용은 3개월 전에 작업했던 내용물들이었기에 다시 정리하느라, 다소 잘 정리되지 못했지만 요약하여 설명해보자면, 수 많은 애니메이션 클립과 노가다를 줄이기 위해 Sprite Libarary Asset을 찾게 되었고, 그 과정에서 노가다를 줄이기 위해 여러 Editor를 만들어보면서, 노가다 작업량을 줄일 수 있었다.
 

Socket과 로그인 인증서버

인증서버

인증서버의 경우 로그인과, 회원가입 부분을 담당하고 있다.
물론 Socket 내에서 DB 조회 및 가입 부분을 만들 수 있었겠지만.
불필요한 Socket 통신을 줄이고 싶었고. 또 현업에 계시는 지인분에게 물어보니 인증서버와 TCP 서버를 나눠서 관리해도 된다 하여, JPA로 인증서버를 구현해보았다.

Controller 소스 코드

package com.flback.backend.RESTAPI; import com.flback.backend.Apiresponse.APIMessageResponse; import com.flback.backend.Entity.Character; import com.flback.backend.Entity.LoginRequest; import com.flback.backend.Entity.Member.Member; import com.flback.backend.Service.CharacterService; import com.flback.backend.Service.MemberService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import javax.json.JsonObject; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @RestController public class MemberController { @Autowired private MemberService memberService; @Autowired private CharacterService characterService; @Autowired private PasswordEncoder passwordEncoder; @PostMapping("/RegisterMember") public ResponseEntity<?> createMember(@RequestBody Member member) { try { Long memberId = memberService.join(member); String message = "회원 가입이 완료되었습니다."; return ResponseEntity.ok().body(new APIMessageResponse(message, true)); } catch (MemberService.MissingRequiredFieldException e) { String errorMessage = e.getMessage(); return ResponseEntity.ok().body(new APIMessageResponse(errorMessage, false)); } catch (IllegalStateException e) { String errorMessage = "이미 존재하는 회원입니다."; return ResponseEntity.ok().body(new APIMessageResponse(errorMessage, false)); } } @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) { try { Member member = memberService.login(loginRequest.getUserId(), loginRequest.getPassword()); // 로그인 성공 처리 String message = "로그인이 성공적으로 완료되었습니다."; Map<String,Object> requestData = new HashMap<>(); requestData.put("memberId",member.getId()); requestData.put("userId",member.getUserId()); requestData.put("name",member.getName()); requestData.put("roleType",member.getRoleType().toString()); requestData.put("max_character_count",member.getMaxCharacterCount()); Map<Long, Character> characters = characterService.getCharactersByMember(member); requestData.put("characters",characters); return ResponseEntity.ok().body(new APIMessageResponse(message, true,requestData)); } catch (IllegalStateException e) { // 로그인 실패 처리 String errorMessage = e.getMessage(); return ResponseEntity.ok().body(new APIMessageResponse(errorMessage, false)); } } }
일단 간단하게
전체적인 흐름을 봤을 때. join으로 해당 멤버 가입이 완료되면 APIMessageResponse를 통해서 클라이언트에게 메세지와 결과 값을 반환해준다.
 

API Message Response

package com.flback.backend.Apiresponse; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; import javax.json.JsonObject; @Getter @Setter public class APIMessageResponse { private String message; private boolean result; private Object data; public APIMessageResponse(String message, boolean result, Object obj) { this.message = message; this.result = result; this.data = obj; } public APIMessageResponse(String message, boolean result) { this(message, result, null); }
메세지 외에 어떠한 데이터가 필요하다면 어떤 데이터가 들어와도 유연하게 반응할 수 있도록 우선은 Object 타입으로 받아오는 것과, 일반 result만 받아올 수 있도록 오버로딩 시켜 놨다.
 

Member

package com.flback.backend.Entity.Member; import com.flback.backend.Entity.Character; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; import org.hibernate.annotations.ColumnDefault; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.ArrayList; import java.util.List; @Getter @Setter @Entity public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name ="MEMBER_ID") private Long id; @NotNull private String userId; @NotNull private String password; private String name; @Enumerated(EnumType.STRING) private RoleType roleType = RoleType.Player; @ColumnDefault("3") private int maxCharacterCount = 3; @OneToMany(mappedBy = "member", fetch = FetchType.EAGER) private List<Character> characterList = new ArrayList<>(); @Transient private PasswordEncoder passwordEncoder; public void setPasswordEncoder(PasswordEncoder passwordEncoder) { this.passwordEncoder = passwordEncoder; } public void addCharacter(Character character) { characterList.add(character); character.setMember(this); } }
Entity를 살펴보자면 primary key가 되는 id와, 회원가입 및 로그인 시 필요한 userId, password가 있고
role Type의 경우 유저 인지 관리자 인지를 구분하기 위해 만들어둔 Enum변수이다.
maxCharacterCount의 경우 최대로 만들 수 있는 캐릭터의 갯수를 의미한다.
해서 characterList의 경우 1:N의 구조로 맵핑 처리가 되어있다.
 
패스워드 인코더 경우, DB내에서 복호화 하기 위해 Spring security를 사용했다.
 

Service

package com.flback.backend.Service; import com.flback.backend.Entity.Member.Member; import com.flback.backend.Repository.MemberRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.security.crypto.password.PasswordEncoder; @Service @Transactional(readOnly = true) public class MemberService { @Autowired MemberRepository memberRepository; @Autowired private PasswordEncoder passwordEncoder; //회원가입 @Transactional public Long join(Member member) { validateRequiredFields(member); boolean validateDuplicate = validateDuplicateMember(member); if (validateDuplicate) { member.setPassword(passwordEncoder.encode(member.getPassword())); // 비밀번호 암호화 memberRepository.save(member); return member.getId(); } else { throw new IllegalStateException("이미 존재하는 회원입니다."); } } private boolean validateDuplicateMember(Member member) { Member findMember = memberRepository.findUserId(member.getUserId()); if(findMember != null) { //회원을 찾을 경우 return false; } else return true; } public Member findMember(long Id) { return memberRepository.findOne(Id); } public Member findUserIdMember(String userId) { return memberRepository.findUserId(userId); } // 필수 입력 필드 체크 메서드 private void validateRequiredFields(Member member) { if (member.getUserId() == null || member.getUserId().isEmpty()) { throw new MissingRequiredFieldException("userId 필드가 누락되었습니다."); } if (member.getPassword() == null || member.getPassword().isEmpty()) { throw new MissingRequiredFieldException("password 필드가 누락되었습니다."); } // 추가적인 필드에 대한 체크 로직을 추가할 수 있습니다. } // 필수 입력 필드 누락 예외 클래스 public class MissingRequiredFieldException extends RuntimeException { public MissingRequiredFieldException(String message) { super(message); } } //로그인 public Member login(String userId, String password) { Member member = memberRepository.findUserId(userId); if (member != null && passwordEncoder.matches(password, member.getPassword())) { return member; } else { throw new IllegalStateException("아이디 또는 비밀번호가 일치하지 않습니다."); } } public void CreateCharacter(){ } }
서비스 부분이다 아까 join을 통해서 이미 존재하는 회원 인지를 검사하는 부분을 확인할 수 있다.
validateDuplicateMember 메소드를 통해서, 해당 유저가 있는지 확인하고 없다면 true를 리턴 받아.
유저가 입력한 id와 password를 암호화 시킨 후, 저장한다.
 

Repository

package com.flback.backend.Repository; import com.flback.backend.Entity.Member.Member; import jakarta.persistence.EntityManager; import jakarta.persistence.NoResultException; import jakarta.persistence.PersistenceContext; import org.springframework.stereotype.Repository; import java.util.List; @Repository public class MemberRepository { @PersistenceContext private EntityManager em; public Member save(Member member) { em.persist(member); return member; } public Member findOne(long id) { return em.find(Member.class,id); } public List<Member> findAll() { return em.createQuery("select m from Member m",Member.class).getResultList(); } /*TODO Ex )public List<Member> findByName(String name) return em.createQuery("select m from Member m where m.name = :name",Member.class).setParameter("name", name).getResultList()*/ public Member findUserId(String userId) { try { return em.createQuery("select m from Member m where m.userId = :userId", Member.class) .setParameter("userId", userId) .getSingleResult(); } catch (NoResultException e) { // 회원을 찾지 못한 경우 null 반환! return null; } } public List<Member> findName(String name) { return em.createQuery("select m from Member m where m.name = :name",Member.class).setParameter("name", name).getResultList(); } public List<Member> findUserIdorName(String userId, String name) { return em.createQuery("select m from Member m where m.name = :name or m.userId = :userId",Member.class).setParameter("name",name). setParameter("userId",userId).getResultList(); } }
레포에 대해선 따로 설명할 것이 없으니 생략하겠다.
 

C# 코드

using Assets.Scripts.UI; using Cysharp.Threading.Tasks; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; public class UI_Register : MonoBehaviour,DefendencyUI { [SerializeField] Text T_Email; [SerializeField] InputField T_Password; [SerializeField] Text T_Name; [SerializeField] Button B_Register; [SerializeField] Button B_Exit; [System.Serializable] struct RegisterFormData { public string userId; public string password; public string name; } private UI_LoginScene parentUI; private void Start() { B_Register.onClick.AddListener(() => { OnRegister(); OnClose(); }); B_Exit.onClick.AddListener(() => { OnClose(); }); } public void OnRegister() { RegisterAsync().Forget(); } async UniTaskVoid RegisterAsync() { //StringDefine.APIURL.RegisterMember; RegisterFormData registerFormData = new RegisterFormData{ userId = T_Email.text, password = T_Password.text, name = T_Name.text }; UnityWebRequest request = await Managers.Apicall.payLoad(StringDefine.APIEndPoint.RegisterMember, registerFormData); if(request.result != UnityWebRequest.Result.Success) { Debug.LogError("Error" + request.error); } else { string requestText = request.downloadHandler.text; Define.APIMessageResponse response = JsonUtility.FromJson<Define.APIMessageResponse>(requestText); if(response.result == true) { parentUI.OnMessageBox("성공했습니다", response.message, OnClose); } else { parentUI.OnMessageBox("실패했습니다", response.message); } } request.Dispose(); } void OnClose() { this.gameObject.SetActive(false); } public void ParentUI(UI_Scene parent) { parentUI = (UI_LoginScene)parent; } }
API 요청을 비동기로 받아야 하기 때문에, UniTask를 사용해서 받는다.
registerFormData는 사용자가 GUI에서 입력한 데이터를 읽어와 request를 던진다.

APICall

using Cysharp.Threading.Tasks; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using UnityEngine; using UnityEngine.Networking; public class APICall { public async UniTask<UnityWebRequest> payLoad(string url,object payLoadData) { string jsonPayload = JsonUtility.ToJson(payLoadData); byte[] postData = Encoding.UTF8.GetBytes(jsonPayload); UnityWebRequest request = new UnityWebRequest("", UnityWebRequest.kHttpVerbPOST); request.url = url; request.uploadHandler = new UploadHandlerRaw(postData); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); await request.SendWebRequest(); return request; } }
API Call의 경우 Register외에도 Login 등에서 쓰이기 때문에 따로 playLoad라는 함수로 빼서 관리했다. 인자중 Data를 object로 받는 이유 또한 Requeset할 데이터가 유연하게 들어올 수 있도록 우선은
object로 해놨다.
요청을 보내고 받는 건 모두 Json을 통해서 받기 때문에, 코드 내용을 json 인코딩 하고 디코딩하는 내용이다.
 

StringDefine

using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; public class StringDefine { private const string serverURL = "http://localhost:8080/"; public struct APIEndPoint { private const string _registerMember = serverURL+"RegisterMember"; public static string RegisterMember = _registerMember; private const string _login = serverURL+"login"; public static string Login = _login; } }
api를 요청할 endpoint의 경우 전역변수로 위와 같이 저장해 두었다.
 

TCP Socket서버

 
using System; using System.Collections.Generic; using System.Data.Common; using System.Diagnostics; using System.Linq; using System.Net; using System.Threading; using Microsoft.EntityFrameworkCore; using MySql.Data.MySqlClient; using Server.DB; using Server.Game; using ServerCore; namespace Server { class Program { static Listener _listener = new Listener(); static List<System.Timers.Timer> _timers = new List<System.Timers.Timer>(); static void TickRoom(GameRoom room, int tick = 100) { var timer = new System.Timers.Timer(); timer.Interval = tick; timer.Elapsed += ((s, e) => { room.Update(); }); timer.AutoReset = true; timer.Enabled = true; _timers.Add(timer); } static void Main(string[] args) { //ConfigManager.LoadConfig(); //DataManager.LoadData(); GameRoom room = RoomManager.Instance.Add(1); TickRoom(room, 50); // DNS (Domain Name System) string host = Dns.GetHostName(); IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); _listener.Init(endPoint, () => { return SessionManager.Instance.Generate(); }); Console.WriteLine("Listening..."); //FlushRoom(); //JobTimer.Instance.Push(FlushRoom); // TODO while (true) { //JobTimer.Instance.Flush(); Thread.Sleep(100); } } // { // using (var context = new DBConnection()) // { // member foundMember = context.member.Find(1L); // memberId가 1인 member 조회 // if (foundMember != null) // { // long memberId = foundMember.member_Id; //string name = foundMember.name; // // memberId를 사용하여 원하는 작업을 수행 // Console.WriteLine($"memberId: {name}"); // } // } // } //static void getCharacter() //{ // using (var context = new DBConnection()) // { // var foundCharacter = context.characters // .Include(c => c.Info) //.Include(c => c.Equips) // .FirstOrDefault(c => c.Id == 0L); // if (foundCharacter != null) // { // Console.WriteLine($"Character: {foundCharacter.Id}"); // Console.WriteLine($"Position: {foundCharacter.PosX}, {foundCharacter.PosY}"); // Console.WriteLine($"Body: {foundCharacter.Equips?.Body}"); // Console.WriteLine($"Head: {foundCharacter.Equips?.Head}"); // Console.WriteLine($"Outfit: {foundCharacter.Equips?.Outfit}"); // Console.WriteLine($"Name: {foundCharacter.Info?.Name}"); // Console.WriteLine($"Sex: {foundCharacter.Info?.Sex}"); // Console.WriteLine($"Age: {foundCharacter.Info?.Age}"); // Console.WriteLine($"History: {foundCharacter.Info?.History}"); // } // } //} } }