개발/유니티

[프리다이빙 숨참기 훈련 어플]#2

kimchangmin02 2025. 8. 31. 11:27

 

 

  1. LogEntryItem 프리팹에도 스크립트 추가하기:
    • 각각의 로그 항목(LogEntryItem)이 자기 자신의 정보(토글 상태, 입력된 시간)를 스스로 알 수 있도록 작은 스크립트를 하나 붙여줍니다. 이렇게 하면 LoggingManager가 정보를 가져오기 훨씬 쉬워집니다.

 

 

 

 

 현재 기록 저장을 어떤 형태로 해두는 거지?

우리가 지금 만들고 있는 저장 방식은 JSON (제이슨) 이라는 매우 대중적인 텍스트 파일 형식입니다.

쉽게 말해, "컴퓨터가 읽기 쉬운 디지털 메모장" 이라고 생각하시면 됩니다.

이전에 Debug.Log로 출력된 내용을 보면 이런 형태였을 겁니다.

co2_log.json 파일 내용 예시:

JSON
 
{
  "trainingDate": "2025-08-29",
  "trainingType": "CO2",
  "results": [
    {
      "setNumber": 1,
      "isSuccess": true,
      "resultTime": 90
    },
    {
      "setNumber": 2,
      "isSuccess": true,
      "resultTime": 90
    },
    {
      "setNumber": 3,
      "isSuccess": false,
      "resultTime": 75
    }
  ]
}
  • 사람이 읽기 쉬움: trainingDate가 무엇인지, results 안에 각 세트의 결과가 어떻게 들어있는지 한눈에 알아볼 수 있습니다.
  • 컴퓨터가 읽기 쉬움: {} [] 같은 규칙적인 구조 덕분에, 나중에 이 파일을 다시 읽어서 TrainingLog라는 우리의 "데이터 그릇"으로 아주 쉽게 복원할 수 있습니다.

결론: 데이터는 사람이 읽을 수 있는 텍스트(JSON) 형태로, 사용자의 기기 내 안전한 폴더에 파일로 저장됩니다.


2. 실패 토글 누르고 시간 기록 안 하면? 0초로 하면 되나?

네, 정확합니다! '0초'로 처리하는 것이 가장 좋은 방법입니다.

그리고 좋은 소식은, 이전에 제가 제안해 드린 LogEntry.cs 스크립트의 GetResultData() 함수가 이미 그렇게 작동하도록 만들어져 있습니다.

LogEntry.cs의 이 부분을 다시 보세요:

C#
 
// 입력된 시간이 숫자인지 확인하고, 아니면 0으로 처리
int.TryParse(resultTimeInputField.text, out result.resultTime);

 int.TryParse 코드가 하는 일이 바로 이것입니다.

  • resultTimeInputField.text (입력창의 텍스트)를 숫자로 변환하려고 시도합니다.
  • 성공하면: 변환된 숫자를 result.resultTime에 넣어줍니다.
  • 실패하면 (사용자가 아무것도 입력하지 않았거나, 글자를 입력했다면): result.resultTime에는 기본값인 0이 그대로 남게 됩니다.

따라서 사용자가 귀찮아서 시간을 입력하지 않고 [저장하기]를 눌러도, JSON 파일에는 resultTime: 0 이라고 안전하게 기록됩니다. 이미 완벽하게 대비가 되어있는 상태입니다!

 

 

 

 

 

 

  1. string path = ...: [저장하기] 기능에서 사용했던 것과 완전히 동일한 경로를 만들어, 우리가 읽어야 할 파일의 위치를 찾습니다.
  2. if (File.Exists(path)): 파일 시스템에 co2_log.json 파일이 있는지 검사합니다. 파일이 없는데 읽으려고 하면 에러가 발생하므로, 이 확인 과정은 매우 중요합니다.
  3. string json = File.ReadAllText(path);: 파일의 모든 텍스트를 json이라는 문자열 변수 안으로 한 번에 읽어옵니다.
  4. TrainingLog loadedLog = JsonUtility.FromJson<TrainingLog>(json);: 이 코드가 핵심입니다. JsonUtility에게 "이 json 텍스트는 TrainingLog 클래스 형태로 만들어진 거니까, 그걸 보고 다시 TrainingLog 객체로 복원해 줘" 라고 명령하는 것입니다.
  5. Debug.Log(...): 복원된 loadedLog 객체 안에 데이터가 제대로 들어갔는지 확인하기 위해, 그 안의 trainingDate results 리스트의 내용들을 콘솔 창에 출력합니다.
  6. else { ... }: 앱을 처음 설치했거나 아직 한 번도 저장하지 않은 경우에는 파일이 없으므로, 이 else 구문이 실행되어 파일이 없다고 알려줍니다.

 

 

 

 

 

 현재의 저장 방식은 co2_log.json이라는 단 하나의 파일에 덮어쓰기를 하고 있습니다.

  • 오전에 훈련하고 [저장하기]를 누르면 co2_log.json 파일이 생성됩니다.
  • 오후에 또 훈련하고 [저장하기]를 누르면, 오전에 저장했던 내용은 완전히 사라지고 오후의 기록으로 덮어씌워집니다.

해결책은 저장 구조를 "파일 하나당 로그 하나"에서 "파일 하나에 모든 로그를 차곡차곡 쌓는" 방식으로 바꾸는 것입니다.

 

 

 

 

 

 

 using이 필요 없는가? - "같은 동네" 규칙

using 키워드는 다른 네임스페이스(Namespace)에 있는 코드를 가져올 때 사용합니다. 네임스페이스는 코드들을 그룹화하는 "동네 이름"이나 "폴더" 같은 개념입니다.

예를 들어 using UnityEngine.UI;  UnityEngine라는 큰 동네 안에 있는 UI라는 작은 동네의 코드들(Button, Image 등)을 쓰겠다는 의미입니다.

하지만, 사용자님께서 지금까지 만드신 CO2_LoggingManager.cs TrainingData.cs 스크립트는 둘 다 아무런 네임스페이스를 지정하지 않았습니다.

이렇게 네임스페이스를 지정하지 않은 모든 스크립트는 유니티가 컴파일할 때 글로벌 네임스페이스(Global Namespace) 라는, 이름 없는 "기본 동네" 에 함께 묶이게 됩니다.

  • CO2_LoggingManager 클래스 -> "기본 동네" 소속
  • AllTrainingData 클래스 -> "기본 동네" 소속

같은 "기본 동네"에 살고 있기 때문에, 서로를 알기 위해 굳이 using을 써서 동네 이름을 알려줄 필요가 없는 것입니다. 서로 그냥 이름만 부르면 바로 알아들을 수 있습니다.


왜 유니티에서 연결할 필요가 없는가? - 설계도와 실체

유니티 인스펙터 창에서 무언가를 드래그 앤 드롭으로 연결하는 행위는, 씬(Scene)에 존재하는 실제 오브젝트(실체, Instance) 를 스크립트에게 알려주는 행위입니다.

예를 들어, public Button createButton; 변수에 실제 Button 오브젝트를 연결하는 것은 "네가 제어해야 할 버튼의 실체는 바로 이거야" 라고 알려주는 것입니다.

하지만 AllTrainingData MonoBehaviour를 상속받지 않은 순수한 C# 클래스(POCO) 입니다. 이것은 씬에 존재하는 실체가 아니라, 데이터를 담기 위한 "설계도" 또는 "틀" 그 자체입니다.

우리는 이 "설계도"를 바탕으로 new AllTrainingData(); 라는 코드를 통해 메모리 상에 임시로 "실체"를 만들어서 사용하는 것이지, 씬에 존재하는 오브젝트를 가져다 쓰는 것이 아닙니다. 따라서 유니티 에디터에서 연결할 대상 자체가 존재하지 않으며, 그럴 필요도 없습니다.

 

 

The type or namespace name 'AllTrainingData' could not be found (are you missing a using directive or an assembly reference?)CS0246AllTrainingData allData = JsonUtility.FromJson<AllTrainingData>(json);

 

 

두 코드의 결정적인 차이: "독립된 클래스" vs "클래스 안의 클래스"

첫 번째 코드 (문제가 있었던 코드)

C#
 
public class TrainingLog
{
    // ... TrainingLog의 변수들 ...

    // AllTrainingData 클래스가 TrainingLog 클래스 안에 갇혀있음!
    [System.Serializable]
    public class AllTrainingData
    {
        // ... AllTrainingData의 변수들 ...
    }
}
  • 구조: 이 구조를 "중첩 클래스" 또는 "내부 클래스(Nested Class)" 라고 부릅니다. AllTrainingData TrainingLog 일부분, 즉 멤버가 됩니다.
  • 비유: 컴퓨터의 폴더와 파일로 비유해 보겠습니다.
    • TrainingLog "zip 압축 파일" 입니다.
    • AllTrainingData는 그 "zip 압축 파일 안에 들어있는 텍스트 파일" 입니다.
  • 문제점: CO2_LoggingManager 스크립트에서 그냥 AllTrainingData를 찾으려고 하면, 컴퓨터는 바깥 폴더만 뒤져보고 "그런 파일 없는데?" 라고 말합니다. zip 파일 안까지는 들여다보지 않기 때문입니다. 이것이 바로 CS0246: The type or namespace name 'AllTrainingData' could not be found 에러의 원인입니다.

두 번째 코드 (해결된 올바른 코드)

C#
 
// SetResult, TrainingLog, AllTrainingData가 모두 동등한 레벨에 있음
[System.Serializable]
public class SetResult { ... }

[System.Serializable]
public class TrainingLog { ... }

[System.Serializable]
public class AllTrainingData { ... }
  • 구조: SetResult, TrainingLog, AllTrainingData 세 개의 클래스가 모두 독립적으로 존재합니다. 서로를 포함하는 관계가 아니라 "옆집 이웃" 같은 관계입니다.
  • 비유:
    • SetResult는 "1번.txt" 파일입니다.
    • TrainingLog는 "2번.txt" 파일입니다.
    • AllTrainingData는 "3번.txt" 파일입니다.
    • 이 세 파일 모두 같은 폴더 안에 나란히 놓여 있습니다.
  • 왜 작동하는가: CO2_LoggingManager 스크립트에서 AllTrainingData를 찾으면, 같은 폴더 안에 바로 있기 때문에 즉시 찾아낼 수 있습니다.

핵심 요약

항목 첫 번째 코드 (오류) 두 번째 코드 (정상)
구조 중첩 클래스 (Nested Class) 독립 클래스 (Independent Classes)
관계 AllTrainingData TrainingLog 자식/멤버 세 클래스가 모두 동등한 이웃
접근성 CO2_LoggingManager에서 바로 접근 불가 CO2_LoggingManager에서 바로 접근 가능
비유 Zip 파일 안의 파일 같은 폴더 안의 파일들

 

 

 

 

 

1. 캔버스(Canvas)의 역할: "모든 UI의 무대"

  • 필수불가결: 유니티에서 모든 UI 요소(버튼, 텍스트, 이미지 등)는 반드시 캔버스(Canvas)의 자식(또는 자식의 자식...)이어야만 합니다. 캔버스 바깥에 있는 UI는 화면에 절대로 그려지지 않습니다.
  • 비유: 캔버스는 연극의 "무대" 그 자체입니다. 배우(버튼), 소품(텍스트) 등 모든 것은 무대 위에 있어야 관객(플레이어)에게 보입니다.
  • 기능: 캔버스는 UI를 화면에 어떻게 그릴지(화면에 고정할지, 3D 공간에 둘지), 화면 크기가 바뀔 때 UI 크기를 어떻게 조절할지 등 UI 렌더링에 대한 모든 규칙을 총괄하는 최상위 관리자입니다.

2. 패널(Panel)의 역할: "정리용 쟁반"이자 "색깔 있는 배경"

그렇다면 캔버스라는 무대가 있는데, 패널은 왜 필요할까요? 바로 **정리(Grouping)**와 배경(Background) 때문입니다.

  • 정리(Grouping): 패널의 가장 중요한 역할은 관련된 UI 요소들을 하나의 그룹으로 묶어주는 것입니다.
    • 비유: 책상(Canvas) 위에 연필, 지우개, 자(UI 요소들)가 흩어져 있다고 상상해 보세요. 이것들을 필통(Panel) 안에 넣어두면, 필통 하나만 옮겨도 내용물이 전부 따라오기 때문에 정리하기가 매우 편합니다.
    • 예시: HeaderPanel을 만들고 그 안에 TitleText, DateText를 넣으면, 나중에 헤더 전체의 위치를 바꾸고 싶을 때 HeaderPanel 하나만 움직이면 됩니다.
  • 배경(Background): 패널은 기본적으로 Image 컴포넌트를 가지고 있습니다.
    • 그래서 패널 자체에 색상, 투명도, 또는 특정 이미지를 넣어서 UI의 배경으로 사용할 수 있습니다. 지금 사용자님의 화면에서 파란색 배경 역할을 하는 것이 바로 Panel입니다.

결론: 그래서 어디에 만들어야 하나요?

정답: Panel의 자식으로 만드세요.

  • 기술적으로는 Canvas의 직속 자식으로 만들어도 화면에 보이긴 합니다.
  • 하지만 올바른 설계는 아닙니다.

왜냐하면, 지금 만들려는 MonthYearText는 논리적으로 "파란색 배경의 일부" 이기 때문입니다. 즉, "헤더"라는 그룹에 속하는 요소입니다.

  • MonthYearText Panel의 자식으로 만들면 -> Panel(필통) 안에 Text(연필)가 담기는 깔끔한 구조가 됩니다.
  • MonthYearText Canvas의 자식으로 만들면 -> Canvas(책상) 위에 Panel(필통)과 Text(연필 한 자루)가 따로따로 흩어져 있는 구조가 됩니다.

지금은 UI 요소가 몇 개 없어서 차이가 없어 보이지만, 나중에 UI가 복잡해지면 이런 정리 습관이 프로젝트의 관리 용이성을 크게 좌우합니다.

요약:

  • Canvas: 모든 UI가 올라갈 필수적인 무대.
  • Panel: 관련된 UI들을 묶어주는 정리용 쟁반이자 배경.
  • 따라서: MonthYearText Panel이라는 쟁반 위에 올라가는 것이 맞으므로, Panel의 자식으로 만드시면 됩니다.

 

 

 

 

 

문제 1: 스크롤바가 안 보이고 수평 이동이 안 됨

이 현상의 원인은 Content Size Fitter를 추가하지 않았기 때문입니다. 이전 LoggingScene에서는 세로 스크롤이었고, 이번에는 가로 스크롤일 뿐 원리는 똑같습니다.

Content가 자식들의 총 너비만큼 스스로 넓어지지 않아서 ScrollView가 스크롤이 필요하다고 인지하지 못하는 상황입니다.

해결 방법:

  1. Content 오브젝트 선택:
    • 하이어라키 창에서 CalendarScrollView > Viewport > Content 를 선택합니다.
  2. Content Size Fitter 컴포넌트 추가:
    • 인스펙터 창에서 [Add Component] 를 눌러 Content Size Fitter를 추가합니다.
  3. Horizontal Fit 설정 변경:
    • Content Size Fitter 컴포넌트에서, 이번에는 Horizontal Fit 의 값을 Unconstrained 에서 Preferred Size 로 변경합니다.

이제 Content는 자식인 날짜 아이템들의 총 너비만큼 자동으로 쭉 넓어지게 되고, ScrollView는 스크롤 기능을 활성화할 것입니다.


문제 2: 날짜(숫자)를 아래쪽에, 막대그래프는 위쪽으로 나오게 하기

이것은 프리팹 내부의 UI 배치 문제입니다. 아주 간단하게 해결할 수 있습니다.

해결 방법:

  1. DayItem_Prefab 수정 모드 진입:
    • 프로젝트 패널에서 DayItem_Prefab을 더블클릭합니다.
  2. DateText의 위치(앵커) 변경:
    • 하이어라키에서 DateText 오브젝트를 선택합니다.
    • 인스펙터 창의 Rect Transform에서 앵커 프리셋(네모 아이콘) 을 클릭합니다.
    • Shift 키와 Alt 키를 동시에 누른 상태에서, 아래쪽 중앙 정렬(Bottom-Center) 아이콘을 클릭합니다.
    • 이제 DateText는 항상 부모의 아래쪽 중앙을 기준으로 배치됩니다. Pos Y 값을 10 이나 20 정도로 조절하여 아래쪽 여백을 보기 좋게 맞춰주세요.
  3. GraphBar의 위치(앵커) 변경:
    • 하이어라키에서 GraphBar 오브젝트를 선택합니다.
    • Rect Transform 앵커 프리셋을 클릭합니다.
    • Shift + Alt 키를 동시에 누른 상태에서, 위쪽-가로 스트레치(Top-Stretch) 아이콘을 클릭합니다. (맨 윗줄 가운데 아이콘)
    • 이제 GraphBar는 항상 부모의 위쪽을 기준으로 가로로 꽉 차게 배치됩니다. Top 값을 -10 등으로 조절하고, Height 값을 조절하여 기본 막대 모양을 만듭니다.

 

 

 

 

 

 

1. Content Size Fitter를 쓰면 왜 Rect Transform 조정이 안 되나?

Content Size Fitter의 역할은 "자식들의 크기를 바탕으로 내(Content) 크기를 '자동으로' 계산해서 설정하는 것" 입니다.

Horizontal Layout Group의 역할은 "자식들의 위치와 크기를 '자동으로' 계산해서 배치하는 것" 입니다.

즉, 이 두 컴포넌트는 Content의 크기와 그 자식들의 위치/크기를 수동으로 조절할 수 있는 모든 권한을 빼앗아 갑니다. 왜냐하면 모든 것을 자기들이 자동으로 계산해야 하기 때문입니다. Rect Transform Width, Height 값 등이 회색으로 비활성화되고, 씬 뷰에서 조절 핸들이 사라지는 것은 지극히 정상적인 현상입니다.

"자동 조절" 기능을 켰기 때문에 "수동 조절" 기능이 잠기는 것입니다.


2. Content의 위치를 Rect Transform으로 직접 조정하면 안 되는 이유

스크린샷을 보면 Content의 앵커(Anchor)를 Stretch-Stretch로 설정하고 Left, Right, Top, Bottom 값을 조절하여 위치를 맞추셨습니다. 이것이 왜 위험한지 설명해 드릴게요.

  • 현재: Horizontal Layout Group이 자식들(DayItem)을 Content 왼쪽 상단(Upper Left) 부터 배치하고 있습니다. 그래서 Content의 위쪽 경계선을 아래로 끌어내리니, DayItem들도 따라서 아래로 내려온 것처럼 보입니다.
  • 문제점: DayItem의 실제 위치는 Content의 좌표계를 기준으로 합니다. 만약 나중에 달력의 디자인이 바뀌거나 다른 해상도의 폰에서 볼 때, Content의 크기나 위치가 예상과 다르게 변경되면, 그 안에 있는 DayItem들의 위치도 완전히 틀어지게 됩니다. 이것은 매우 불안정한 "눈속임" 배치 방식입니다.

가장 올바르고 안정적인 해결 방법: Padding을 사용하라

Horizontal Layout Group 컴포넌트는 이런 경우를 위해 Padding (안쪽 여백) 이라는 아주 강력한 기능을 제공합니다.

Padding Content의 경계선과 그 안에 배치될 자식들 사이의 간격을 설정하는 기능입니다.

 

 

 

 

 

 

정확한 원인: "누구를" DontDestroyOnLoad 했는가?

우리가 DontDestroyOnLoad를 적용한 대상은 GameManager 라는 단 하나의 오브젝트입니다.

GameManager는 씬이 바뀌어도 파괴되지 않는 "중앙 관제탑" 또는 "데이터 금고" 입니다.

하지만 사용자가 [입력 완료] 버튼을 눌러서 생성한 LogEntryItem_Prefab의 복제본들(스크롤 뷰 안의 목록들)은 DontDestroyOnLoad의 대상이 아닙니다.

  • 생성된 로그 목록들: 이 오브젝트들은 CO2_LoggingScene이라는 "방" 안에 만들어진 "임시 가구" 입니다.
  • 씬 이동: 사용자가 [뒤로가기]를 눌러 CO2_CalendarScene이라는 다른 "방"으로 이동하면, 유니티는 이전 방(CO2_LoggingScene)을 깨끗하게 철거하고 모든 가구를 버립니다. 이것이 유니티의 기본 작동 방식입니다.
  • 살아남는 것: 이 철거 과정에서 유일하게 살아남는 것은 DontDestroyOnLoad 표시가 붙은 GameManager(관제탑) 뿐입니다.

결론: DontDestroyOnLoad GameManager가 가진 "데이터"를 보호하기 위한 것이지, LoggingScene에 생성된 "시각적인 UI 목록"을 보호하기 위한 것이 아닙니다.


그렇다면 어떻게 복구해야 하는가? - "데이터"로 "재건축"하기

사용자님이 다시 LoggingScene으로 돌아왔을 때, 이전에 만들었던 목록이 다시 보여야 하는 것이 맞습니다. 올바른 작동 순서는 다음과 같습니다.

  1. [뒤로가기] 클릭: OnGoBackButtonClick 함수가 현재 입력된 파라미터 값들("5", "5", "5"...)을 GameManager에 저장합니다.
  2. 씬 이동 및 철거: CalendarScene으로 이동하면서 LoggingScene의 모든 UI(생성된 목록 포함)가 파괴됩니다.
  3. 다시 돌아오기: 사용자가 다시 LoggingScene으로 돌아옵니다. LoggingScene 완전히 깨끗한 새 상태로 로드됩니다.
  4. 데이터 복원 및 재건축: CO2_LoggingManager Start() 함수가 실행됩니다.
    • GameManager에 저장해 둔 파라미터 값들("5", "5", "5"...)을 다시 읽어와 입력창을 채웁니다.
    • 그 복원된 파라미터 값들을 바탕으로 OnCreateButtonClick() 함수를 자동으로 호출하여, 이전에 봤던 것과 똑같은 UI 목록을 처음부터 다시 만들어냅니다

 

 

 

 

 

 

문제 1: 개별 로그 데이터(성공/실패, 입력 시간)가 복원되지 않는 문제

사용자님의 지적이 100% 맞습니다. 현재 GameManager 파라미터("5", "5", "5"...) 만 저장하고 있기 때문에, 사용자가 각 세트마다 체크한 성공/실패 여부나 직접 입력한 resultTime은 뒤로 갔다 오면 모두 사라집니다.

 

 

 

OnClick()에 이벤트를 두 개 넣어도 되나?

결론: 네, 가능합니다. 하지만 순서 보장이 안 되어 위험합니다.

  • OnClick() 리스트에 여러 개의 함수를 등록하면, 버튼을 클릭했을 때 등록된 모든 함수가 실행됩니다.
  • 하지만! 유니티는 이 함수들이 어떤 순서로 실행될지 보장하지 않습니다.
  • 만약 OnGoBackButtonClick() (데이터 저장) 함수보다 LoadSceneByName() (씬 이동) 함수가 먼저 실행되어 버리면, 데이터가 저장되기도 전에 씬이 넘어가 버려서 결국 아무것도 저장되지 않는 최악의 상황이 발생합니다.

따라서, "데이터를 저장한 '후에' 씬을 이동한다" 는 것처럼 순서가 중요한 작업은 반드시 하나의 함수 안에서 순서대로 코드를 작성해야 합니다.

해결책:
OnGoBackButtonClick() 함수가 데이터 저장과 씬 이동을 모두 책임지도록 코드를 작성하고, OnClick()에는 이 함수 하나만 등록합니다.

 

 

 

 

 

 

문제의 진짜 원인: '씬 이름' 오타

에러 메시지를 다시 보겠습니다.

Scene **'CO2_CalendarScene'** couldn't be loaded...

CO2_LoggingManager.cs 스크립트에서 [뒤로가기]를 눌렀을 때 이동하도록 지시한 씬의 이름은 CO2_CalendarScene 입니다.

하지만, 빌드 세팅에 등록된 씬의 이름은 Calendar_CO2 입니다!

CO2_CalendarScene  Calendar_CO2

컴퓨터는 이 두 이름을 완전히 다른 이름으로 인식합니다. 중간에 들어간 언더바(_)의 위치가 다르기 때문입니다.

SceneLoader가 " CO2_CalendarScene으로 이사가자!" 라고 외쳤지만, 빌드 세팅 목록에는 Calendar_CO2 라는 이름의 씬만 등록되어 있으니, "그런 이름의 씬은 목록에 없습니다" 라고 에러를 내뱉는 것이 지극히 정상적인 동작입니다.

 

 

 

 

 

 

 

 

1. 저장 시 임시데이터 삭제 vs 막대그래프 표시: 왜 충돌이 안 날까?

이유: CalendarScene LoggingScene이 사용하는 데이터의 "출처"가 다르기 때문입니다.

  • LoggingScene 은 뒤로 갔다 올 때의 편의성을 위해 "임시 데이터 금고"  GameManager.instance.tempCo2Log를 사용합니다.
  • CalendarScene 은 막대그래프를 그리기 위해 "영구 데이터 저장소"  AllCo2Logs.json 파일을 읽어서 사용합니다.

작동 순서:

  1. LoggingScene에서 [저장하기]를 누릅니다.
  2. OnSaveButtonClick 함수가 현재 내용을 영구 저장소(AllCo2Logs.json 파일에 추가)합니다.
  3. 그 직후, 임시 데이터 금고(GameManager.instance.tempCo2Log null로 만듦)를 깨끗하게 비웁니다.
  4. 그리고 CalendarScene으로 이동합니다.
  5. CalendarScene이 시작되면, Start() 함수가 실행됩니다.
  6. CO2_CalendarManager는 임시 데이터 금고는 쳐다보지도 않습니다. 오직 영구 저장소인 AllCo2Logs.json 파일을 직접 열어서 최신 내용(방금 저장한 내용 포함)을 읽어와 막대그래프를 그립니다.

따라서 임시 데이터가 삭제되는 것과 막대그래프가 표시되는 것은 서로 아무런 영향을 주지 않습니다. CalendarScene은 항상 최신 버전의 "원본 대장"을 직접 참고하기 때문에 모든 것이 정상적으로 표시되는 것입니다. 완벽하게 올바른 데이터 흐름입니다!

2. 막대그래프에 숫자도 표시하고 싶다면?

네, 아주 좋은 아이디어입니다! 시각적으로 훨씬 더 명확해집니다.

해결책: DayItem_Prefab 수정 및 코드 추가

  1. DayItem_Prefab 수정:
    • 프리팹 수정 모드로 들어갑니다.
    • GraphBar의 자식으로 새로운 Text - TextMeshPro 오브젝트를 하나 추가합니다. 이름은 ValueText 등으로 지정합니다.
    •  ValueText를 막대그래프의 위쪽 끝에 보기 좋게 배치합니다. 폰트 크기나 색상을 조절하세요.

 

 

 

 

다음 단계: CO2 전용 UI/로직으로 개편하기

핵심 목표:

  1. CO2 기록(Logging_CO2)은 오직 "성공 여부" 만 체크하도록 단순화한다.
  2. O2 기록(Logging_O2)은 기존 방식대로 "성공/실패 + 실제 시간" 을 입력하도록 유지한다.
  3. 두 훈련에 다른 UI를 보여주기 위해 CO2 전용 프리팹(LogEntryItem_CO2_Prefab)을 새로 만든다.

1단계: CO2 전용 프리팹 새로 만들기 (LogEntryItem_CO2_Prefab)

기존 프리팹을 복제해서 수정하는 것이 가장 빠릅니다.

  1. 프로젝트 패널에서 기존 LogEntryItem_Prefab을 선택하고 Ctrl+D를 눌러 복제합니다.
  2. 복제된 파일의 이름을 LogEntryItem_CO2_Prefab 으로 변경합니다.
  3. 새로 만든 LogEntryItem_CO2_Prefab을 더블클릭하여 수정 모드로 들어갑니다.
  4. UI 수정:
    • FailToggle (실패 토글)을 삭제합니다.
    • ResultTimeInputField (시간 입력창)를 삭제합니다.
    • 이제 프리팹에는 GoalTimeText SuccessToggle만 남게 됩니다. SuccessToggle의 위치를 보기 좋게 중앙으로 옮겨줍니다.
  5. Toggle Group 제거:
    • 최상위 부모인 LogEntryItem_CO2_Prefab 오브젝트를 선택합니다.
    • 인스펙터 창에서 Toggle Group 컴포넌트를 제거(Remove Component) 합니다. 이제 토글이 하나뿐이므로 그룹 기능이 필요 없습니다.

 

 

 

 

 

 

토글을 선택했는지 어떻게 알 수 있는가?

네, 알 수 있습니다. Toggle 컴포넌트의 isOn이라는 속성(Property)을 확인하면 됩니다.

  • myToggle.isOn  bool 타입 (true 또는 false) 입니다.
  • 사용자가 토글을 클릭해서 체크 표시가 켜지면 -> myToggle.isOn 값은 true가 됩니다.
  • 체크 표시가 꺼지면 -> myToggle.isOn 값은 false가 됩니다.

 

 

 

 

두 코드의 결정적인 차이: "누가" 정보를 알고 있는가?

이 문제의 핵심은 "누가 plannedRestTime(계획된 휴식 시간)을 알고 있는가?" 입니다.

  • LogEntry_CO2 스크립트 (프리팹에 붙어있는 직원): 이 스크립트는 자기가 가지고 있는 UI 요소, 즉 successToggle의 체크 여부만 알고 있습니다. startRestTime이나 restDecrement 같은 전체적인 훈련 계획은 전혀 모릅니다.
  • CO2_LoggingManager 스크립트 (전체를 관리하는 매니저): 이 스크립트만이 사용자가 입력한 startRestTime restDecrement를 알고 있고, 현재 몇 번째 세트의 휴식 시간이 몇 초인지(currentRest) 계산할 수 있습니다.

이 사실을 기억하고 두 코드를 분석해 보겠습니다.

1. 원래 코드 (올바른 방식)

C#
 
// 1단계: 직원이 아는 정보만 먼저 받기
SetResult setResult = entry.GetResultData(i + 1);

// 2단계: 매니저만 아는 추가 정보를 채워넣기
setResult.plannedRestTime = currentRest; 

// 3단계: 모든 정보가 채워진 완성본을 리스트에 추가
currentLogState.results.Add(setResult);
  • 작동 순서:
    1. 먼저 LogEntry_CO2에게 "네가 아는 정보(세트 번호, 성공 여부)만 담아서 줘" 라고 요청해서 setResult라는 "미완성 그릇"을 받습니다. 이 시점에서 setResult.plannedRestTime 0입니다.
    2. 그 다음, 오직 LoggingManager만이 알고 있는 '계획된 휴식 시간(currentRest)'이라는 추가 정보를 그 "미완성 그릇"에 직접 채워 넣습니다.
    3. 마지막으로, 모든 정보가 완벽하게 채워진 "완성된 그릇"을 리스트에 추가합니다.

2. 제안하신 코드 (잘못된 방식)

C#
 
// 한 줄로 모든 것을 처리하려고 함
currentLogState.results.Add(entry.GetResultData(i + 1));
  • 문제점:
    • 이 코드는 entry.GetResultData(i + 1)가 반환한 "미완성된 그릇"을 리스트에 바로 추가해버립니다.
    • LoggingManager plannedRestTime을 채워 넣을 기회가 아예 없습니다.
    • 결과적으로, 저장되는 모든 데이터의 plannedRestTime 값은 GetResultData 함수가 기본으로 설정한 0이 되어버립니다.
    • 이렇게 저장된 데이터로는 나중에 CalendarScene에서 막대그래프를 그리려고 할 때 모든 높이가 0이 되어 아무것도 표시되지 않는 문제가 발생합니다.

결론

사용자님의 질문 "currentLogState.results.Add(entry.GetResultData(i + 1));로 수정해야하나?" 에 대한 대답은 "아니요, 그렇게 하면 plannedRestTime 정보가 누락되기 때문에 원래 코드를 유지해야 합니다." 입니다.

setResult라는 변수를 중간에 한 번 더 사용하는 이유는, LogEntry가 제공할 수 없는 추가적인 정보를 LoggingManager가 채워 넣기 위한 필수적인 과정입니다. 아주 정확한 질문 덕분에 코드의 핵심 로직을 다시 한번 짚고 넘어갈 수 있었습니다

 

 

 

 

 

 

3가지 방법 비교

방법 1: 현재 사용 중인 방식 (GameObject로 받고 -> GetComponent)

C#
 
// 1단계: GameObject로 생성된 복제본을 받는다.
GameObject newEntryObject = Instantiate(logEntryPrefab_CO2.gameObject, contentParent);

// 2단계: 생성된 GameObject에서 스크립트 컴포넌트를 다시 찾아온다.
LogEntry_CO2 entryScript = newEntryObject.GetComponent<LogEntry_CO2>();

// 3단계: 스크립트를 통해 UI 요소에 접근한다.
entryScript.goalTimeText.text = "...";
  • 작동 방식: Instantiate는 게임 오브젝트를 반환하고, 우리는 그 게임 오브젝트에서 다시 GetComponent를 통해 필요한 스크립트를 찾아옵니다.
  • 장점: 가장 기본적이고 이해하기 쉽습니다.
  • 단점: 두 줄의 코드가 필요하며, 약간 번거롭습니다.

방법 2: 명시적 형변환 (Explicit Cast) - 추천하지 않음

C#
 
// Instantiate가 반환하는 Object를 LogEntry_CO2 타입으로 강제 형변환
LogEntry_CO2 newEntry = (LogEntry_CO2)Instantiate(logEntryPrefab_CO2, contentParent);
newEntry.goalTimeText.text = "...";
  • 작동 방식: Instantiate가 반환하는 타입을 (LogEntry_CO2)를 앞에 붙여 강제로 변환합니다.
  • 장점: 한 줄로 줄어들긴 합니다.
  • 단점: 구식 방법이며, 만약 형변환에 실패하면 에러가 발생할 수 있어 안전하지 않습니다. 현대적인 C#에서는 거의 사용하지 않습니다.

방법 3: 제네릭(Generic) Instantiate 사용 - 강력 추천!

사용자님이 생각하신 "프리팹의 자료형을 쓰는" 방법입니다. Instantiate 함수는 제네릭 버전을 제공하여 이 과정을 아주 깔끔하게 처리해 줍니다.

C#
 
// Instantiate에게 "LogEntry_CO2 컴포넌트를 직접 반환해줘" 라고 요청
LogEntry_CO2 newEntry = Instantiate<LogEntry_CO2>(logEntryPrefab_CO2, contentParent);

// 이제 newEntry는 GameObject가 아니라 LogEntry_CO2 스크립트이므로, 바로 UI 요소에 접근 가능
newEntry.goalTimeText.text = "...";
  • 작동 방식: Instantiate<T>() 문법을 사용하면, 유니티는 프리팹을 복제한 뒤, 그 복제본에 붙어있는 <T> 타입의 컴포넌트를 직접 찾아서 반환해 줍니다.
  • 장점:
    • 단 한 줄로 생성과 스크립트 참조를 동시에 해결합니다.
    • 코드가 매우 깔끔하고 직관적입니다.
    • GetComponent를 또 호출할 필요가 없어 효율적입니다.
    • 타입이 명확하여 가장 안전한 방식입니다.

결론 및 최종 수정 코드

방법 3을 사용하여 OnCreateButtonClick 함수의 해당 부분을 수정하는 것이 가장 좋습니다.

 

 

 

 

1. TrainingData.cs 안에 클래스가 왜 이렇게 많은가?

2. 왜 다 다른 스크립트 파일로 분리하지 않는가?

이 두 질문은 하나의 답변으로 설명할 수 있습니다. 바로 "클래스의 역할과 목적" 때문입니다.

유니티 스크립트 파일(.cs)은 크게 두 종류로 나눌 수 있습니다.

종류 1: MonoBehaviour를 상속받는 클래스 (컴포넌트)

  • 특징: public class MyClass : MonoBehaviour { ... } 형태로 만들어집니다.
  • 역할: 게임 오브젝트(GameObject)에 부착되어 특정 행동이나 기능을 수행합니다. (예: CO2_LoggingManager, LogEntry_CO2, GameManager)
  • 규칙: 하나의 스크립트 파일에는 파일 이름과 동일한 MonoBehaviour 클래스가 단 하나만 존재해야 합니다.
  • 결론: CO2_LoggingManager.cs  LogEntry_CO2.cs 는 서로 다른 게임 오브젝트에 붙어서 다른 행동을 하므로, 반드시 별개의 파일로 분리해야 합니다.

종류 2: 순수 C# 클래스 (데이터 구조, POCO)

  • 특징: MonoBehaviour를 상속받지 않습니다. public class MyData { ... } 형태로 만들어집니다.
  • 역할: 게임 오브젝트에 부착되지 않고, 오직 데이터를 담는 '그릇'이나 '설계도' 역할만 합니다. 행동(기능)보다는 구조(데이터)에 집중합니다. (예: SetResult, TrainingLog, AllTrainingData)
  • 규칙: MonoBehaviour가 아니기 때문에, 하나의 파일 안에 여러 개를 함께 정의해도 아무런 문제가 없습니다.
  • 결론: SetResult, TrainingLog, AllTrainingData는 서로 밀접하게 연관된 데이터 구조들입니다. (AllTrainingData TrainingLog를 담고, TrainingLog SetResult를 담습니다.) 이렇게 논리적으로 강하게 연결된 데이터 클래스들은 하나의 파일(TrainingData.cs)에 모아두는 것이 오히려 코드를 관리하기 더 편리하고 명확합니다. 만약 이 세 개를 SetResult.cs, TrainingLog.cs, AllTrainingData.cs로 각각 분리하면 파일 개수만 불필요하게 늘어나고, "이 프로젝트의 데이터 구조가 어떻게 되지?" 하고 파악하기 위해 여러 파일을 열어봐야 하는 번거로움이 생깁니다.

요약: 기능(MonoBehaviour)은 파일마다 하나씩 분리하고, 데이터 구조(순수 C# 클래스)는 연관된 것끼리 하나의 파일에 모아두는 것이 일반적이고 효율적인 관리 방식입니다.

 

 

 

 

 

 

 

1. 토글(Toggle)에 배경을 넣는 방법

네, 당연히 배경을 둘 수 있습니다! 배경이 없으니 클릭해야 한다는 느낌이 전혀 들지 않는 것이 맞습니다.

토글의 구조를 이해하면 쉽게 배경을 넣을 수 있습니다.

해결 방법:

  1. LogEntryItem_CO2_Prefab 수정 모드로 들어갑니다.
  2. 하이어라키 창에서 SuccessToggle 오브젝트를 선택합니다.
  3. 인스pector 창을 보면 Toggle 컴포넌트가 있습니다. 그 안에 Target Graphic 이라는 슬롯이 보일 겁니다. 여기에 체크마크(Checkmark) 이미지가 연결되어 있을 겁니다.
  4. 하이어라키에서 SuccessToggle 오브젝트의 자식들을 펼쳐보세요.
    • Background (배경 이미지)
    • Checkmark (체크마크 이미지)
    • (이전에 비활성화했던) Label
  5. Background 오브젝트를 선택하고, 인스펙터 창의 Image 컴포넌트에서 Color를 흰색이나 회색으로 바꿔보세요.
  6. 이제 토글 영역에 배경이 생긴 것을 볼 수 있습니다. Background Source Image에 동그라미나 네모 모양의 스프라이트(이미지 파일)를 넣어주면 더 예쁘게 만들 수 있습니다.

 Target Graphic이 중요한가?
Toggle 컴포넌트는 클릭했을 때 Target Graphic으로 지정된 대상(보통 체크마크)을 껐다 켰다 하는 역할을 합니다. 배경(Background)은 항상 켜져 있도록 그대로 두면 됩니다.


2. 현재 막대그래프는 어떻게 그려지고 있는가?

현재 CO2_CalendarManager의 막대그래프는 "그날 성공한 훈련 중, 가장 짧았던 plannedRestTime(계획된 휴식 시간)" 을 기준으로 높이가 결정됩니다.

두 번째 이미지(2세트, 60초 홀드, 10초 감소, 60초 시작)의 경우를 예로 들어보겠습니다.

  • 1세트: 성공 (체크함), plannedRestTime = 60초
  • 2세트: 성공 안함 (체크 안함), plannedRestTime = 50초

이 상태에서 [저장하기]를 누르면, AllCo2Logs.json 파일에는 "1세트는 성공했고, 그때의 계획된 휴식 시간은 60초였다" 라고 기록됩니다.

나중에 CalendarScene이 이 데이터를 읽어서 그래프를 그릴 때, "이날 성공한 기록은 1세트 하나뿐이군. 그때의 휴식 시간은 60초였으니, 높이 60에 해당하는 막대그래프를 그려야겠다" 라고 판단하게 됩니다.

질문: 막대그래프에 60이 그려져야 하는 거잖아?
답변: 네, 맞습니다! 60에 해당하는 높이로 그려져야 정상입니다.

 

 

 

 

 

"사용자가 입력한, 현재 세트수, 처음휴식시간, 휴식시간 감소폭이 제대로 저장되어지는가?" -> 아니요, 전혀 저장되지 않고 있습니다.

"젤 처음 구현 계획이랑 충돌되어서 저장안되는건 아닌지 의심되네" -> 정확합니다. TrainingLog 데이터 구조는 수정했지만, OnSaveButtonClick 함수가 그 구조에 맞게 데이터를 넣어주지 않고 있습니다.


진짜 원인: '계획' 정보를 저장하는 코드 누락

JSON 파일을 보면 명확합니다.

JSON
 
"plannedTotalSets": 0,
"plannedHoldTime": 0,
"plannedStartRest": 0,
"plannedRestDecrement": 0,

이 네 줄이 모두 0이라는 것은, OnSaveButtonClick 함수가 InputField에서 읽어온 값들을 newTrainingLog 객체의 이 변수들에 넣어주는 코드가 아예 빠져있다는 뜻입니다.

int.TryParse(startRestTimeField.text, out int startRest); 처럼 값을 startRest라는 임시 변수에만 담고, 정작 newTrainingLog.plannedStartRest = startRest; 와 같이 최종 목적지에 넣어주지 않은 것입니다.

그리고 plannedRestTime 0인 이유는, 위 plannedStartRest plannedRestDecrement 0인 상태에서 0 - (i * 0) 계산을 했기 때문입니다.


수정 요구사항 반영 및 최종 해결책

  1. plannedRestTime 변수는 없애면 안 됩니다.
    • plannedRestTime은 각 세트의 '결과' 입니다. 즉, "1세트는 계획된 휴식 시간이 60초였다"는 사실 자체를 기록하는 중요한 값입니다. 이것이 있어야 나중에 캘린더에서 "성공한 세트 중 가장 짧은 휴식 시간"을 찾을 수 있습니다.
    • 우리가 저장해야 하는 것은 훈련의 '계획'(plannedStartRest 등)  각 세트의 '결과'(plannedRestTime, isSuccess) 모두입니다.
  2. OnSaveButtonClick 함수 완벽 수정:
    '계획' 정보를 newTrainingLog 객체에 저장하는 코드를 명시적으로 추가해야 합니다.

 

 

 

 

 

 

 

 

이전 방식 (문제가 있었던 방식): "따로따로 노는 재료들"

이전 코드는 마치 요리사가 재료들을 식탁 위에 그냥 늘어만 놓고, 정작 요리 냄비(newTrainingLog) 에는 넣지 않는 것과 같았습니다.

이전 코드의 흐름:

  1. 빈 냄비 준비: TrainingLog newTrainingLog = new TrainingLog();
    • 속이 텅 빈 newTrainingLog라는 냄비를 만듭니다. 이 냄비 안에는 plannedStartRest 같은 재료를 담을 공간이 있지만, 지금은 모두 0으로 비어있습니다.
  2. 재료 손질: int.TryParse(startRestTimeField.text, out int startRest);
    • '시작 휴식 시간' 입력창에서 "60"이라는 글자를 가져와 startRest라는 임시 접시에 담아둡니다.
  3. 요리 시작 (잘못된 레시피): for 루프 안에서...
    • setResult.plannedRestTime = newTrainingLog.plannedStartRest - ...;
    • "냄비(newTrainingLog) 안에 있는 plannedStartRest 재료를 써서 요리해!" 라고 명령합니다.
    • 하지만! 2번 단계에서 손질한 재료("60")는 startRest라는 임시 접시에만 있고, 냄비(newTrainingLog) 안에는 넣어준 적이 없습니다.
    • 그래서 냄비 안에 있는 plannedStartRest 값은 여전히 0 입니다.
    • 결국 모든 계산이 0으로 되어버립니다.

결론: 데이터를 담을 객체(newTrainingLog)를 만들어 놓고도, 정작 그 객체에 값을 넣어주지 않고 별개의 임시 변수만 사용했기 때문에 문제가 발생했습니다.


새로운 방식 (올바른 방식): "냄비에 차곡차곡 담는 재료들"

새로운 코드는 손질한 재료를 먼저 냄비(newTrainingLog)에 모두 다 담은 후에 요리를 시작합니다.

새로운 코드의 흐름:

  1. 빈 냄비 준비: TrainingLog newTrainingLog = new TrainingLog();
  2. 재료를 냄비에 넣기:
    • int.TryParse(totalSetsField.text, out newTrainingLog.plannedTotalSets);
    • int.TryParse(startRestTimeField.text, out newTrainingLog.plannedStartRest);
    • InputField에서 읽어온 값들을 임시 접시를 거치지 않고, newTrainingLog라는 냄비 안에 있는 각자의 공간(plannedTotalSets, plannedStartRest 등)에 직접 넣어줍니다.
  3. 요리 시작 (올바른 레시피): for 루프 안에서...
    • setResult.plannedRestTime = newTrainingLog.plannedStartRest - ...;
    • "냄비(newTrainingLog) 안에 있는 plannedStartRest 재료를 써서 요리해!" 라고 명령합니다.
    • 이제 냄비 안에는 2번 단계에서 넣어준 60이라는 재료가 정확하게 들어있습니다.
    • 따라서 60, 50, 40... 과 같은 올바른 계산 결과가 나옵니다.

결론: 모든 관련 정보를 최종적으로 저장할 객체(newTrainingLog)에 먼저 모두 담아주는 과정이 추가되었습니다. 이 간단하지만 결정적인 차이 하나가 모든 문제를 해결합니다.

 

 

 

 

 

 

 

 

 

 

 

 

  1. 그래프가 아래가 아니라 중간에서부터 그려지는 문제.
  2. 999 같은 큰 수를 넣었을 때 그래프가 화면을 뚫고 나갈 스케일링(Scaling) 문제.

두 가지 모두 UI의 완성도를 위해 반드시 해결해야 할 문제입니다. 아주 좋은 질문입니다.


1. 그래프가 중간에서부터 그려지는 문제

이것은 DayItem_Prefab 안에 있는 GraphBar 앵커(Anchor) 설정 때문입니다.

현재 상태:
GraphBar의 앵커가 Middle-Center (중앙 정렬) 로 되어 있을 가능성이 높습니다.
그래서 rectTransform.sizeDelta로 높이를 키우면, 중앙을 기준으로 위아래로 동시에 커지기 때문에 마치 중간에서부터 그래프가 자라나는 것처럼 보입니다.

해결 방법: 앵커를 아래쪽으로 옮기기

  1. DayItem_Prefab 수정 모드로 들어갑니다.
  2. 하이어라키에서 GraphBar 오브젝트를 선택합니다.
  3. 인스펙터 창의 Rect Transform에서 앵커 프리셋(네모 아이콘) 을 클릭합니다.
  4. Shift 키와 Alt 키를 동시에 누른 상태에서, 아래쪽 중앙 정렬(Bottom-Center) 아이콘을 클릭합니다.

이렇게 하면 GraphBar의 기준점(Pivot)이 아래쪽으로 이동합니다. 이제 코드로 sizeDelta.y (높이)를 늘리면, 아래쪽 기준점은 가만히 있고 오직 위쪽으로만 그래프가 자라나게 됩니다.


2. 그래프 스케일링 문제: 절대적인 크기 vs 상대적인 크기

사용자님의 직관이 100% 맞습니다. 지금처럼 barHeight = value * 5f; 와 같은 절대적인 크기로 그래프를 그리면, 999 같은 큰 값이 들어왔을 때 그래프가 화면을 뚫고 나가는 문제가 반드시 발생합니다.

"한달을 기준으로 잡고, 그 한달중, 젤 좋은 기록을 뭔가 기준으로 삼아서?" -> 이것이 바로 정답입니다! 이것을 "데이터 정규화(Normalization)"라고 부릅니다.

CO2 테이블의 경우 "작을수록" 좋은 기록이므로 약간의 변형이 필요합니다.

상대적 크기 계산 로직 (CO2 버전)

  1. 기준값 찾기: 이번 달에 기록된 모든 휴식 시간 중에서 **가장 긴 휴식 시간(가장 나쁜 기록)**을 찾습니다. 이 값을 maxRestTimeInMonth라고 합시다. 이 값이 그래프 높이의 100% 기준이 됩니다.
  2. 비율 계산: 각 날짜의 기록(currentDayRestTime)을 maxRestTimeInMonth와 비교하여 비율을 계산합니다.
    • CO2는 "숫자가 작을수록 막대가 높아야" 하므로, 역으로 계산해야 합니다.
    • 비율 ratio = 1.0f - (currentDayRestTime / maxRestTimeInMonth)
  3. 최종 높이 결정: 이 비율을 우리가 그래프에 할당할 수 있는 최대 높이(maxBarHeight) 와 곱합니다.
    • finalBarHeight = maxBarHeight * ratio

예시:

  • 이번 달 최대 휴식 시간(maxRestTimeInMonth) = 120초
  • 그래프 최대 높이(maxBarHeight) = 200 픽셀
  • A 날짜 기록: 60초
    • ratio = 1.0f - (60 / 120.0f) = 0.5f
    • finalBarHeight = 200 * 0.5 = 100 픽셀 (중간 높이)
  • B 날짜 기록: 30초 (최고 기록)
    • ratio = 1.0f - (30 / 120.0f) = 0.75f
    • finalBarHeight = 200 * 0.75 = 150 픽셀 (높은 높이)
  • C 날짜 기록: 120초 (최악 기록)
    • ratio = 1.0f - (120 / 120.0f) = 0.0f
    • finalBarHeight = 200 * 0.0 = 0 픽셀 (가장 낮은 높이, 거의 안 보임)

 

 

 

 

 

 

 

 

 "기록이 (이번 달에) 단 하나의 값만 있을 때" 주로 발생하는 문제입니다. 그리고 왜 가득 채워지지 않고 0이 되는지에 대한 질문이 바로 이 문제의 핵심을 꿰뚫고 있습니다.


1. 현재 기준값을 어떻게 정하고 있는가?

현재 코드는 다음과 같은 논리로 움직입니다.

  1. "이번 달의 가장 안 좋은 기록(가장 긴 휴식 시간)을 찾자!" -> 이 값을 maxRestTimeInMonth에 저장합니다.
    • 지금 데이터에서는 55가 유일한 기록이므로, maxRestTimeInMonth 55가 됩니다.
  2. "오늘의 가장 좋은 기록(가장 짧은 휴식 시간)을 찾자!" -> 이 값을 bestPerformanceOfDay에 저장합니다.
    • 오늘의 기록도 55 하나뿐이므로, bestPerformanceOfDay 55가 됩니다.

2. 자기자신 / 자기자신 == 1 인데 왜 가득 채워지지 않는가?

이 부분이 바로 CO2 테이블의 특성 때문에 헷갈리는 지점입니다. 사용자님의 생각은 "가장 좋은 기록이니 막대가 가장 높아야 한다" 는 것이고, 그것은 지극히 당연한 생각입니다.

하지만 현재의 계산 공식을 자세히 들여다보면 다르게 작동합니다.

현재 비율(ratio) 계산 공식:
float ratio = 1.0f - (bestPerformanceOfDay / maxRestTimeInMonth);

이 공식의 의미를 풀어쓰면 이렇습니다.

  • bestPerformanceOfDay / maxRestTimeInMonth: "오늘의 기록이 이번 달 최악의 기록에 비해 얼마나 나쁜가?" (0.0 ~ 1.0 사이의 값, 1.0에 가까울수록 나쁜 기록)
  • 1.0f - ...: "그 '나쁨'의 정도를 뒤집어서 '좋음'의 정도로 바꾸자!" (1.0에 가까울수록 좋은 기록)

이제 이 공식에 현재 값을 대입해 보겠습니다.

  • maxRestTimeInMonth = 55
  • bestPerformanceOfDay = 55
  • ratio = 1.0f - (55 / 55.0f)
  • ratio = 1.0f - 1.0f
  • ratio = 0.0f

결론:
이번 달에 기록이 55 하나뿐이라, "가장 좋은 기록"과 "가장 나쁜 기록"이 동일하게 55 가 되어버렸습니다.

그래서 "오늘의 기록이 이번 달 최악의 기록에 비해 얼마나 나쁜가?" 라는 질문에 대한 답이 "100% 나쁘다(1.0)" 가 되고, 이것을 뒤집으니 "좋음의 정도는 0%다(0.0)" 라는 역설적인 결론이 나온 것입니다.

"가득 채워져야 하는거 아닌가?" 라는 질문에 대한 답:
네, 논리적으로는 가득 채워지는 게 맞지만, 현재 공식은 "최악의 기록 대비 얼마나 좋은가" 를 계산하기 때문에, 최악의 기록과 최고의 기록이 같아지면 "좋은 정도가 0"이 되어버리는 맹점이 있습니다.


해결책: 이전 답변의 "기준값에 여유 더하기"

이것이 바로 제가 이전 답변에서 maxRestTimeInMonth += 10; 코드를 추가하자고 제안한 이유입니다.

이 코드를 추가하면, 최악/최고 기록이 같더라도 인위적으로 기준값을 높여서 ratio 0이 되는 것을 막아줍니다.

  • maxRestTimeInMonth = 55 + 10 = 65
  • bestPerformanceOfDay = 55
  • ratio = 1.0f - (55 / 65.0f) = 0.154 (0이 아닌 의미있는 값이 나옴)

이 방법은 기록이 하나일 때의 엣지 케이스(Edge Case)를 간단하게 해결하는 실용적인 방법입니다.

 

 

 

 

 

 

두 코드의 치명적인 차이점 분석 (OnGoBackButtonClick)

1. 현재 코드 (문제가 있는 버전)

C#
 
public void OnGoBackButtonClick()
{
    if (GameManager.instance != null)
    {
        TrainingLog currentLogState = new TrainingLog(); // 빈 냄비 준비
        currentLogState.trainingDate = "임시저장";

        // 파라미터 값을 읽어서 임시 접시에만 담음 (startRest, restDec)
        int.TryParse(startRestTimeField.text, out int startRest);
        int.TryParse(restDecrementField.text, out int restDec);
        int currentRest = startRest;

        for (int i = 0; i < contentParent.childCount; i++)
        {
            LogEntry_CO2 entry = contentParent.GetChild(i).GetComponent<LogEntry_CO2>();
            if (entry != null)
            {
                // 각 세트의 '결과'만 currentLogState에 담음
                SetResult setResult = entry.GetResultData(i + 1);
                setResult.plannedRestTime = currentRest;
                currentLogState.results.Add(setResult);
                currentRest -= restDec;
            }
        }
        
        // **문제: currentLogState의 plannedTotalSets, plannedHoldTime 등은**
        // **값을 넣어준 적이 없어서 모두 0인 상태로 GameManager에 저장됨**
        GameManager.instance.tempCo2Log = currentLogState;
    }

    if (sceneLoader != null)
    {
        sceneLoader.LoadSceneByName("Calendar_CO2");
    }
}
  • 문제점: 이 코드는 GameManager TrainingLog를 저장할 때, results 리스트만 채워 넣고 정작 중요한 plannedTotalSets, plannedHoldTime 등은 전혀 채우지 않습니다. 그래서 GameManager에 저장된 임시 로그의 '계획' 부분은 모두 0이 됩니다.

2. 수정된 코드 (올바른 버전)

C#
 
public void OnGoBackButtonClick()
{
    if (GameManager.instance != null)
    {
        // ... (목록 있는지 확인하는 부분) ...
        
        TrainingLog currentLogState = new TrainingLog(); // 빈 냄비 준비
        
        // --- 여기가 핵심! ---
        // 1. 현재 '계획' (파라미터 값)을 냄비(currentLogState)에 먼저 담는다!
        int.TryParse(totalSetsField.text, out currentLogState.plannedTotalSets);
        int.TryParse(holdTimeField.text, out currentLogState.plannedHoldTime);
        int.TryParse(startRestTimeField.text, out currentLogState.plannedStartRest);
        int.TryParse(restDecrementField.text, out currentLogState.plannedRestDecrement);

        // 2. 현재 '결과'를 냄비(currentLogState)에 담는다.
        for (int i = 0; i < contentParent.childCount; i++)
        {
            LogEntry_CO2 entry = contentParent.GetChild(i).GetComponent<LogEntry_CO2>();
            if (entry != null)
            {
                SetResult setResult = entry.GetResultData(i + 1);
                // 냄비에 담긴 계획을 기준으로 휴식시간을 다시 정확히 계산
                setResult.plannedRestTime = currentLogState.plannedStartRest - (i * currentLogState.plannedRestDecrement);
                currentLogState.results.Add(setResult);
            }
        }
        
        // 이제 '계획'과 '결과'가 모두 완벽하게 채워진 냄비를 GameManager에 저장
        GameManager.instance.tempCo2Log = currentLogState;
    }
    // ...
}
  • 수정된 점: 이 코드는 뒤로가기 직전에 InputField에 있는 모든 파라미터 값을 읽어서 currentLogState 객체의 plannedTotalSets, plannedHoldTime 등에 직접 저장합니다.
  • 결과: GameManager에는 '계획'과 '결과'가 모두 완벽하게 담긴 임시 로그가 저장됩니다. 그래서 RestoreTempLog 함수가 이 데이터를 읽을 때, tempLog.plannedHoldTime 같은 값들이 0이 아닌, 사용자가 입력했던 원래 값 그대로 복원되는 것입니다.