지금까지 우리는 Sokoban 맵을 그려내기 위해 코드 속에서 좌표와 string을 하나하나 직접 지정했습니다. 이 방식은 좌표를 하나하나 지정하기 번거롭고, 코드로 작성한 맵을 출력하여 확인하기 전에는 완성된 맵의 모양을 확인하기 어렵다는 단점이 있었는데요. 여러 버전(스테이지 등)의 맵을 별도의 파일에 그려둔 후, 그 파일을 읽어와 프로그램 내에서 처리할 수는 없을까요? 그 방법에 대해 알아봅시다.
: 정보를 저장하기 위한 단위로, 프로그램 또한 파일의 일종입니다. 운영체제가 파일 시스템을 통해 관리합니다.
1) 사람이 읽을 수 있는 기호로 구성된 파일
2) 텍스트 에디터를 이용해 저장된 정보(데이터)를 확인할 수 있음
3) 예시
1) 텍스트 파일 외의 파일들
2) 예시
1) 파일의 위치 정보
2) 운영체제의 파일 시스템을 통해 특정 파일에 접근하기 위해 경로를 알아야 함
1) 파일의 절대적인 위치 정보
2) 예시
1) 특정 파일을 기준으로 표현한 위치 정보
2) 예시
→ 프로그래밍에선 일반적으로 상대 경로를 많이 이용합니다. 그 이유는 프로그램이 실행될 모든 컴퓨터의 디렉토리 구성이 같을 수 없기 때문입니다. 상대 경로를 이용할 경우 개발 환경과 다른 상황에서도 알맞은 경로를 찾아갈 수 있으므로 절대 경로에 비해 유연한 방식이라고 할 수 있습니다.
Windows는 경로에 \가 포함되어있어 코드 작성시 이스케이프 시퀀스 처리가 힘들 수 있습니다. Path Class의 Path.Join과 같은 기능을 이용하면 이와같은 처리를 보다 쉽게 할 수 있으며, 복잡한 경로일수록 Path Class를 사용하 처리하는 것이 좋습니다.
: 아래 예제들은 모두 현재 프로젝트 파일에 파일을 생성했기에 비주얼스튜디오의 솔루션 탐색기에서 솔루션 우클릭 > 파일 탐색기에서 열기 > bin > Debug (디버그 모드로 실행한 경우) > net6.0 (사용 버전)
에서 확인할 수 있습니다. 이는 프로그램이 생성된 경로와 같습니다.
1) 텍스트 파일 작성을 위해 사용할 수 있는 메소드
2) File.WriteAllText("파일 경로", "입력할 데이터");
3) 사용 예시
File.WriteAllText("Sample", "Hello World!\nThis is sample.");
1) 텍스트 파일 작성을 위해 사용할 수 있는 메소드
2) File.WriteAllLines("파일 경로", 입력할 데이터(배열));
3) 사용 예시
string[] contents =
{
"Hello World!",
"This is sample2."
};
File.WriteAllLines("Sample2", contents);
1) 텍스트 파일에 내용을 추가할 수 있는 메소드
2) File.AppendAllText("파일 경로", "추가할 데이터");
3) 사용 예시
File.AppendAllText("Sample.txt", "\nThis is new sentence");
1) 텍스트 파일에 내용을 추가할 수 있는 메소드
2) File.AppendAllLines("파일 경로", 추가할 데이터(배열));
3) 사용 예시
string[] contents2 =
{
"This is new sentence"
};
File.AppendAllLines("Sample2.txt", contents2);
1) 이미 존재하는 파일의 경로로 WriteAllText()와 WriteAllLines()를 실행하는 경우 새로운 입력 데이터로 덮어쓰기 합니다.
2) 존재하지 않는 파일 경로로 AppendAllText(), AppendAllLines()를 실행하는 경우 파일을 새로 생성하여 내용을 추가합니다.
1) 텍스트 파일을 읽어올 수 있는 메소드
string text = File.ReadAllText("Sample.txt");
1) 텍스트 파일을 읽어올 수 있는 메소드
1) 경로에 파일이 존재하지 않는 경우 오류가 발생합니다. 리소스(파일)를 다루는 것은 예외가 많다는 것을 유의하고 사용해야 하며, 예외를 적절히 처리해야 합니다.
2) 리소스란 운영체제로부터 빌려오는 자원으로, 사용 후 반환해야 합니다. File 메소드는 파일을 닫는 기능이 구현되어 있지만, StreamWriter 및 StreamReader 방식은 그렇지 않습니다.
using (StreamWriter file = new StreamWriter("Sample.txt"))
file.WriteLine("Hello World");
위와 같이 using문을 사용한 경우 범위를 벗어나면 파일을 반환합니다. 혹은 Dispose 메소드를 사용할 수 있습니다.
1) 프로젝트 폴더에 Assets > Stage 경로 생성
2) 메모장을 이용해 맵을 작성 (인코딩 방식에 유의해야 함)
public static string[] LoadStage(int stageNumber)
{
// 파일을 불러온다 -> 한줄로 끊어오는 방식이 좌표 구성에 편할 것
// 1. 경로를 구성한다.
string stageFilePath = Path.Combine("Assets", "Stage", $"Stage{stageNumber:D2}.txt"); // Assets > Stage > Stage01.txt 같은 파일 불러옴
Console.WriteLine(stageFilePath);
// D2 : 포맷팅 방법 두자리를 채우는데 빈 공간은 0으로
// Debug.Asset(false); 혹은 Debug.Assert(false, "여기가 고장남"); 과 같이 함께 출력할 메세지를 입력할 수도 있음
// 따라서 잘못된 데이터를 허용한다면 조건문을 이용할 수 있고, 아예 허용하지 않는 경우 Asset를 이용
// 2. 파일이 존재하는지 확인
if (!File.Exists(stageFilePath))
{
ExitWithError($"스테이지 파일이 없습니다. 스테이지 번호({stageNumber})");
Environment.Exit(-1);
// 이건 유저한테도 보이는 메세지
// 단정문은 디버그 용도라는 관점
}
// 3. 파일의 내용을 불러온다.
return File.ReadAllLines(stageFilePath);
// Tip > stageNumber도 1 2 와같은 단순 숫자 말고 열거형으로 구성해서 Stage의 특성을 나타내는 걸로 바꿔도 됨
}
1) 안전하게 프로그래밍을 하기 위한 기법으로, 조건문이 참인지 검사하여 시스템이 의도대로 구축되었는지 검사하는 용도로 사용합니다.
2) 추후 알고리즘을 이용해 함수를 만들 때, 사전 조건과 사후 조건을 확인하며 보통 이 조건들을 확인하기 위하여 단정문을 사용합니다.
3) if문과의 차이
string[] lines = Game.LoadStage(1);
// 불러온 파일을 한 줄씩 출력
for(int i = 0; i < lines.Length; ++i)
{
Console.WriteLine(lines[i]);
}
public static void ParseStage(string[] stage, out Player player, out Box[] boxes, out Wall[] walls, out Goal[] goals)
{
// 이 함수는 이미 파일을 읽어온 후(LoadStage 후) 실행하기 때문에 반드시 stage가 null이 아니어야 함
// stage == null인 걸 허용할 수 없는 상태이므로 Asset를 사용해야 함 (if말고)
Debug.Assert(stage != null);
player = null;
// 1. 파일의 첫 줄에서 메타데이터를 파싱 (벽의 수 / 박스의 수 / 골의 수)
string[] stageMetadata = stage[0].Split(" ");
walls = new Wall[int.Parse(stageMetadata[0])];
boxes = new Box[int.Parse(stageMetadata[1])];
goals = new Goal[int.Parse(stageMetadata[2])];
int wallIndex = 0;
int boxIndex = 0;
int goalIndex = 0;
for(int y = 1; y < stage.Length; ++y)
{
for(int x = 0; x < stage[y].Length; ++x)
{
switch (stage[y][x])
{
// y - 1을 y좌표로 사용하는 이유는 텍스트파일의 첫 줄은 게임의 메타데이터를 저장하는 데 이용했기 때문
case ObjectSymbol.Player:
player = new Player { X = x, Y = y - 1 };
break;
case ObjectSymbol.Wall:
walls[wallIndex] = new Wall { X = x, Y = y - 1 };
++wallIndex;
break;
case ObjectSymbol.Box:
boxes[boxIndex] = new Box { X = x, Y = y - 1 };
++boxIndex;
break;
case ObjectSymbol.Goal:
goals[goalIndex] = new Goal { X = x, Y = y - 1 };
++goalIndex;
break;
case ' ': // 맵의 공백인 부분은 스페이스로 입력했기에 읽어온 데이터가 공백 한 칸일 때 아무것도 처리하지 않는다는 구문을 넣어줌
break;
default:
// 데이터는 항상 조작될 수 있기에 검증을 거치는 코드가 필요함
// 'P', '#', 'B', 'G', ' '을 제외한 문자가 포함된 경우 잘못된 맵임을 default로 처리
ExitWithError("스테이지 파일이 잘못되었습니다.");
break;
}
Player player;
Box[] boxes;
Wall[] walls;
Goal[] goals;
int pushedBox = 0;
Game.ParseStage(lines, out player, out boxes, out walls, out goals);
오늘 배운 방법은 맵 외에도 프로그램을 구성하는 다양한 데이터를 파일로 저장하고, 그 파일을 다루기 위해 활용할 수 있습니다.
+) 파일을 더욱 효율적이고 쉽게 관리할 수 있도록 데이터를 적절히 분리하여 저장할 필요가 있습니다.