Unity 본캠프 10일차 - C# / TRPG 제작

한예준·2025년 4월 18일

오늘은 TRPG의 마무리 작업을 하고, C#강의를 마저 수강할 예정이다.
어제 TRPG의 주요 기능들을 전부 완성했고, 이제 Json을 통해 저장/불러오기 기능을 구현해보겠다. 만약 시간이 남으면 추가적인 기능들을 더 구현해보고 마무리하겠다.


JSON 기반의 데이터 저장/불러오기 기능을 구현하기 전에 먼저 JSON이 무엇인지 알아보겠다.

JSON

  • JavaScript Object Notation
  • 텍스트 기반의 데이터 표현 방식
  • 데이터를 사람과 기계 모두 읽고 쓰기 쉬운 텍스트 형식으로 표현하기 위한 경량의 데이터 교환 포맷이다.

JSON의 특징

  • 키/값 쌍으로 데이터를 구성
  • 문자열, 숫자, 불리언, 배열, 객체, null 등 기본 타입 지원
  • 언어 독립적이지만 자바스크립트 문법과 호환
  • 가독성이 좋고, 대부분의 프로그래밍 언어에 라이브러리가 제공되어 직렬화/역직렬화 용이

JSON을 쓰는 이유

  • 사람이 읽고 쓰기 편함
  • 언어/플랫폼 독립으로 다양한 환경에서 데이터 교환 가능
  • 표준화된 포맷이라 거의 모든 언어에서 지원
  • 파일 입출력이나 네트워크 전송 시에도 그대로 사용

게임에서 세이브 데이터를 저장하거나, 서버와 상태를 주고받을 때 JSON을 사용하면 편리하다.


TRPG에서의 JSON 구현

JSON 활용법

  • 세이브 파일: 플레이어의 상태(이름, 레벨, 체력, 골드 등)와 인벤토리 정보를 JSON으로 직렬화하여 save.json 같은 파일로 저장한다.
  • 로딩: 프로그램 시작 혹은 "불러오기" 기능으로 JSON 파일을 읽어들여 역직렬화한 뒤, 해당 데이터를 PlayerInventory에 적용해 이전 세션을 이어갈 수 있다.
  • 편집/디버깅: JSON 파일은 사람이 읽고 편집하기에도 쉽기 때문에 저장된 값을 직접 수정해 테스트해볼 수도 있다.

현재 프로젝트에서의 활용

  • 현재 게임에서 저장해야 할 데이터
    • Player 정보
      • Name(string)
      • Job (string)
      • Level, Exp (int)
      • Hp, MaxHp (int)
      • Atk, Def (float)
      • Gold (int)
    • Inventory 정보
      • 아이템 리스트(Item.AllItems) 중 IsOccupied == true인 아이템들
      • 보유 아이템 리스트 중(Inventory.Items) 중 IsEquipped == true인 아이템들
      • 각 아이템에 대해
        고유 식별자(Name또는 인덱스)
        IsEquipped (bool)
        IsOccupied (bool)

실제 적용

  • SaveData.cs를 생성.
  • PlayerItem에 필요한 데이터들을 자동 구현 프로퍼티를 이용하여 접근한다.
// SaveData.cs
public class SaveData
{
    public PlayerData player { get; set; }
    public List<ItemData> Inventory { get; set; }
}

public class PlayerData
{
    public string Name { get; set; }
    public string Job { get; set; }
    public int Level { get; set; }
    public int Exp { get; set; }
    public int Gold { get; set; }
    public int Atk { get; set; }
    public int Def { get; set; }
    public int MaxHp { get; set; }
    public int Hp { get; set; }
}

public class ItemData
{
    public string Name { get; set; }
    public string Type { get; set; }
    public int Atk { get; set; }
    public int Def { get; set; }
    public string Description { get; set; }
    public int Price { get; set; }
    public bool IsEquipped { get; set; }
    public bool IsOccupied { get; set; }
}
  • SaveManager.cs 생성
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;

public static class SaveManager
{
    private static readonly string SaveFilePath = "save.json";

    // 게임 데이터를 JSON으로 저장
    public static void SaveGame()
    {
        // PlayerData 객체에 현재 플레이어 상태를 담는다
        var playerDto = new PlayerData
        {
            Name = Player.Name,
            Job = Player.Job,
            Level = Player.Level,
            Exp = Player.Exp,
            Hp = Player.Hp,
            MaxHp = Player.MaxHp,
            Atk = (int)Player.Atk,
            Def = (int)Player.Def,
            Gold = Player.Gold
        };

        // Inventory에 보유 중인 아이템들만 DTO로 변환
        var inventoryDto = Item.AllItems
            .Where(item => item.IsOccupied)
            .Select(item => new ItemData
            {
                Name = item.Name,
                Type = item.Type,
                Atk = item.Atk,
                Def = item.Def,
                Description = item.Description,
                Price = item.Price,
                IsEquipped = item.IsEquipped,
                IsOccupied = item.IsOccupied
            })
            .ToList();

        // 최종 저장 구조체
        var saveData = new SaveData
        {
            player = playerDto,
            Inventory = inventoryDto
        };

        // JSON 직렬화(들여쓰기)
        var options = new JsonSerializerOptions
        {
            WriteIndented = true,
            Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
        };
        string json = JsonSerializer.Serialize(saveData, options);

        // 파일에 쓰기 (없으면 생성, 있으면 덮어쓰기)
        File.WriteAllText(SaveFilePath, json);
    }

    // 저장된 JSON을 읽어와 게임 상태를 복원
    public static bool LoadGame()
    {
        if (!File.Exists(SaveFilePath))
            return false;  // 저장 파일이 없으면 로드 실패

        string json = File.ReadAllText(SaveFilePath);
        var saveData = JsonSerializer.Deserialize<SaveData>(json);
        if (saveData == null)
            return false;

        // Player 상태 복원
        var pd = saveData.player;
        Player.Name = pd.Name;
        Player.Job = pd.Job;
        Player.Level = pd.Level;
        Player.Exp = pd.Exp;
        Player.Hp = pd.Hp;
        Player.MaxHp = pd.MaxHp;
        Player.Atk = pd.Atk;
        Player.Def = pd.Def;
        Player.Gold = pd.Gold;

        // Inventory 초기화 후, 저장된 아이템들만 적용
        foreach (var item in Item.AllItems)
        {
            item.IsOccupied = false;
            item.IsEquipped = false;
        }

        foreach (var id in saveData.Inventory)
        {
            var item = Item.AllItems.FirstOrDefault(x => x.Name == id.Name);
            if (item != null)
            {
                item.IsOccupied = true;
                item.IsEquipped = id.IsEquipped;
            }
        }

        // Inventory 리스트 갱신
        Inventory.renew();

        return true;
    }
}

게임 시작시 LoadGame() 메서드를 불러올 수 있도록 Program.cs를 수정했다.

//게임 시작씬
            Console.WriteLine(" 1. 새 게임   ");
            Console.WriteLine(" 2. 불러오기  ");
            Console.WriteLine(" 3. 종료      ");
            Console.WriteLine("");
            Console.Write(">> ");

            var choice = Console.ReadLine();
            if (choice == "1")
            {
                Player.Reset();
                PlayerSetup.Start(); 
                MainScene.Start();
                break;
            }
            else if (choice == "2")
            {
                if (SaveManager.LoadGame())
                {
                    Console.Clear();
                    MainScene.Start();
                    break;
                }
                else
                {
                    Console.WriteLine("\n저장된 게임이 없습니다.");
                    Console.WriteLine("새 게임을 시작하려면 아무키나 누르세요.");
                    Console.ReadKey();

                    Player.Reset();
                    PlayerSetup.Start(); 
                    MainScene.Start();
                }
            }
            else if (choice == "3")
            {
                return;
            }
            else
            {
                Console.Clear();
                Console.WriteLine("\n잘못된 입력입니다. 다시 선택해주세요.");
                Console.ReadKey();
            }
        }

또한 각 메뉴에서 나올때마다 저장하도록 수정하였다.

// 예시: 휴식하기
            Console.WriteLine("0. 나가기");
						.
						.
						.
            string input = Console.ReadLine();
            if (input == "0")
            {
                Console.Clear();
                Console.WriteLine("게임이 저장되었습니다.");
                SaveManager.SaveGame();
                Thread.Sleep(1000);
                Console.Clear();
                break;
            }

몇 가지 자잘한 오류들을 수정하고 마무리했다.


C# 수강

먼저 라이브 세션에서 제시한 반복문을 이용하여 직각 삼각형 모양의 별을 찍는 과제를 간단하게 수행해보겠다.

for (int i = 0; i < 5; i++)
{
    for (int j = 0; j <= i; j++)
    {
        Console.Write("*");
    }
    Console.WriteLine();
}
  • 코드 진행 순서
    i = 0, 바깥 for 문 안에 있는 내용을 실행.
    {
    j = 0, j 가 i (=0)과 같아질 때까지 출력 실행 (1번).
    }
    한 칸 띄우기 실행.
    i++ 실행.
    i = 1, 바깥 for 문 안에 있는 내용을 실행.
    {
    j = 0, j 가 i (=1)과 같아질 때까지
    출력 실행 (2번).
    }
    한칸 띄우기 실행.
    i++ 실행.
    .
    .
    .
    i++ 실행.
    i = 5, i < 5 조건에 만족하지 못하므로 실행 중지.

델리게이트

Delegate란, "위임하다"라는 뜻으로, 어떤 메서드를 개체에게 위임하게 해주는 역할을 한다.

  • Delegate 정의
// 반환형이 int, 매개변수로 int 두 개를 받는 델리게이트
public delegate int Operation(int x, int y);
  • 인스턴스화 및 메서드 연결
// Delegate의 타입에 맞는 메서드
int Add(int a, int b) => a + b;

// 델리게이트 변수에 메서드 연결
Operation op = Add;
  • 호출
int result = op(3, 4);  // Add(3,4)를 호출한 것과 동일하게 7을 반환
  • 멀티 캐스트
op += Subtract;         // Subtract(int,int)도 연결
op(10, 5);              // 내부적으로 Add → Subtract 순서대로 호출
  • 델리게이트를 쓰는 이유, 장점
    • 특정 매개변수 타입과 반환타입을 가지는 타입을 정의한다.
    • 해당 시그니처를 만족하는 메서드를 += 연산으로 델리게이트에 연결하면, 델리게이트를 호출할 때 그 메서드가 실행된다.
    • 같은 매개변수타입을 가진 메서드를 여러 개 연결해두면, 델리게이트 한 번의 호출로 차례대로 모두 실행된다.
    • 런타임 중에 +=, -= 등으로 관리할 수 있어서, 실행 중에도 호출 목록을 동적으로 변경할 수 있다.
    • 여러 메서드를 일괄 실행해야 하거나, 메서드를 유연하게 관리해야 할 때 유용하다.
// 델리게이트 구조와 출력 예시
using System;

namespace DelegateExample
{
    // 1. 델리게이트 선언: string 하나를 받는 void 반환형
    public delegate void Notify(string message);

    class Program
    {
        // 2. 델리게이트 인스턴스
        static Notify notifier;

        static void Main(string[] args)
        {
            // 3. 메서드 연결
            notifier += ShowOnScreen;
            notifier += LogToConsole;

            Console.WriteLine("프로그램 시작");

            // 4. 델리게이트 호출: 연결된 두 메서드가 순서대로 실행
            notifier("Delegate 호출 테스트");

            // 5. ShowOnScreen 제거 후 다시 호출
            notifier -= ShowOnScreen;
            notifier("두 번째 호출");

            Console.WriteLine("프로그램 종료");
        }

        // 6-1. 델리게이트에 연결된 첫 번째 메서드
        static void ShowOnScreen(string msg)
        {
            Console.WriteLine($"[Screen] {msg}");
        }

        // 6-2. 델리게이트에 연결된 두 번째 메서드
        static void LogToConsole(string msg)
        {
            Console.WriteLine($"[Log]    {msg}");
        }
    }
}

프로그램 시작
[Screen] Delegate 호출 테스트
[Log]    Delegate 호출 테스트
[Log]    두 번째 호출
프로그램 종료

람다

  • 람다
    • 익명 메서드를 만드는 방법, 메서드를 이름없이 만들 수 있다.
    • 델리게이트를 사용하여 변수에 할당하거나, 메서드의 매개변수로 전달할 수 있다.
  • 람다 구현
// 메서드로 구현했을 때
      static void Main()
    {
        int result = Add(3, 5);
        Console.WriteLine($"정식 메서드 결과: {result}");
    }
      static int Add(int x, int y)
    {
        return x + y;
    }
//람다로 구현했을 때
    static void Main()
    {
        // 1) Func 델리게이트에 람다 대입
        Func<int, int, int> add = (x, y) => x + y;

        int result = add(3, 5);
        Console.WriteLine($"람다 표현식 결과: {result}");
    }
  • 람다의 장점
    • 간단한 호출에 편리하다.
    • 코드가 훨씬 간결해진다.
    • 메서드 선언부를 따로 만들 필요 없이, 사용 위치에 바로 정의 가능.
  • 람다와 메서드의 사용 차이(람다의 단점)
    • 복잡한 로직에 사용하기 불편하다.
    • 메서드는 이름이 있어서 한눈에 파악이 가능하다.
    • 같은 로직을 여러곳에 쓰기에는 메서드로 사용하는 것이 편리함.
    • 지역 변수처럼 사용 범위가 좁다.
    • 오버로딩/오버라이딩이 불가능
    • 람다는 "짧고 일회적인" 용도로, 메서드는 "명확하고 재사용 가능한" 용도로 사용하는 것이 좋다.

LINQ

  • LINQ란?

    • Language Integrated Query의 약자
    • C#/.NET 코드 안에서 SQL 같은 “쿼리 문법”을 바로 쓸 수 있게 해 주는 기능
    • 컬렉션(List, 배열 등), 데이터베이스, XML 등 다양한 데이터 소스에 공통된 방식으로 필터링(Where), 정렬(OrderBy), 그룹화(GroupBy), 조인(Join) 등을 할 수 있다
  • 기본 구조

     var 결과 = 
      from 항목 in 데이터소스   // ① 데이터의 출발점
      [where 조건식]            // ② (선택) 필터링
      [orderby 정렬키]          // ③ (선택) 정렬
      select 변환식;            // ④ (선택) 꺼내올 값
    • var: 결과 컬렉션의 타입을 컴파일러가 자동으로 결정
    • from: 순회할 원본 데이터 지정
    • where: 조건에 맞는 항목만 걸러내기
    • orderby: 결과를 원하는 순서대로 정렬하기
    • select: 각 항목에서 최종적으로 꺼내올 값을 지정
  • 장점

    • 코드가 간결해진다.
    • 직관적이다.
    • 여러 데이터 소스에도 적용할 수 있다.

Nullable

일반적인 값 형식 변수에는 null값을 가질 수 없지만, nullable을 사용하면 null값을 허용할 수 있다.

  • Nullable을 쓰는 이유

    • 데이터베이스 필드나 폼 입력처럼 “값이 있을 수도, 없을 수도” 있는 경우
      예: 점수, 날짜, 금액 등이 불출력(미입력) 상태일 수 있음
  • 사용 예시

int? score = null;        // 아직 점수가 없다는 의미
if (score.HasValue)       
    Console.WriteLine(score.Value);
else
    Console.WriteLine("점수가 없습니다.");

score = 85;
Console.WriteLine(score.GetValueOrDefault());  // 85
  • Nullable 사용의 장점
    • 명확한 값 없음 표현
    • 코드의 안전성 강화

StringBuilder

일반 string은 불변(immutable)이어서, 문자열을 수정할 때마다 내부적으로 새 인스턴스를 할당해야 한다.
StringBuilder는 내부에 가변 버퍼(buffer)를 두고, 이 버퍼를 직접 조작해 문자열을 추가·삽입·삭제·치환할 수 있도록 설계된 클래스이다.

  • StringBuilder의 용도
    • 긴 텍스트를 단계별로 조립하거나, 루프 안에서 여러 번 문자열을 덧붙일 때
    • 로그 메시지, HTML/SQL/JSON 템플릿 생성 등 많은 문자열 조작이 필요한 상황
  • 주요 메서드
    • Append(string s): 버퍼 끝에 문자열 s를 추가
    • AppendLine(string s): Append(s) 후 Environment.NewLine 추가
    • Insert(int pos, string s): 버퍼의 pos 위치에 문자열 s를 삽입
    • Remove(int pos, int len): pos 위치부터 len 문자만큼 삭제
    • Replace(string old, string @new): 버퍼 전체에서 old를 찾아 new로 모두 교체
    • Clear(): Length = 0으로 설정해 내용을 모두 삭제, Capacity는 유지
    • ToString(): 현재 버퍼 내용을 string으로 반환
    • EnsureCapacity(int min): 최소 min 용량이 되도록 버퍼 크기 조정
    • Capacity 프로퍼티: 현재 버퍼 최대 크기 조회/설정
    • Length 프로퍼티: 현재 버퍼에 저장된 문자 수 조회/설정
  • 사용 예시
class Program
{
    static void Main()
    {
        // 1) 초기용량 지정 없이 생성 (기본 16)
        StringBuilder sb = new StringBuilder();

        // 2) Append, AppendLine
        sb.Append("Hello");
        sb.Append(' ');
        sb.AppendLine("World!");  
        // sb: "Hello World!\r\n"

        // 3) Insert
        sb.Insert(5, ",");        
        // sb: "Hello, World!\r\n"

        // 4) Replace
        sb.Replace("World", "C#");
        // sb: "Hello, C#!\r\n"

        // 5) Remove
        sb.Remove(sb.Length - 2, 2);  
        // sb: "Hello, C#!"

        // 6) 최종 출력
        Console.WriteLine(sb.ToString());
        // 출력: Hello, C#!
    }
 }

회고 및 느낀점

오늘도 한시간정도 일찍 출석하여 공부를 진행했다. 어제와 마찬가지로 집중이 흐트러지지는 않았지만, 마음이 급해서인가, 아직 시간이 부족하다는 생각이 든다.
TRPG 프로젝트 JSON 파일 생성 및 저장/불러오기 기능까지 적용하는 것으로 마무리했다.
유니티 에디터를 사용하면 조금 더 직관적인 구현이 가능했을 것 같다는 생각은 들지만, C#을 통해서만 구현하다보니 지금까지 배운 C# 문법의 활용에 대한 전반적인 이해가 올라간 것 같다.
하지만 이후 배운 내용들에 대한 응용은 아직 많이 힘들다.
이런 프로젝트를 계속 반복하고 따로 또 만들어 보면서 특정 기능들을 어느 상황에서 사용해야 하고 사용하지 말아야 하는지에 대한 감각을 살리는 것이 중요하다고 생각한다.

강의를 통해 델리게이트, 람다 등의 이점과 한계를 모두 살펴보았다.
지금까지 프로젝트를 진행할 때는 "구현이 되느냐 마느냐"에 집중했는데, 이를 통해 "코드 가독성"과 "유지보수성" 등을 모두 신경써야 한다는 점을 다시금 깨달았다.
이 기능들도 복습하고 적용해보며 상황별로 적절하게 사용할 수 있도록 노력하겠다.

다음주부터 팀 프로젝트를 시작한다는 얘기가 도는데, 팀에 코딩을 많이 접해보신 분들도 계시고, 기획 쪽을 공부하신 분들도 계시다고 하여 내심 안심이 되긴하지만, 그 속에서도 반드시 내 역할을 드러내보여야 한다고 생각한다.
지금까지 배운 내용들을 적절히 적용해보는 것에 집중하고, 많은 커뮤니케이션과 리뷰를 통해 이번 기회를 많이 배울 수 있는 기회로 삼겠다.

0개의 댓글