(C#) 16. 간단한 Text RPG 게임 구현하기
enum·struct·함수·ref/out을 한 번에 써서 만든 첫 게임. 죽어도 필드를 못 벗어나는 버그, Random을 함수마다 새로 만들어 같은 몬스터만 나오는 문제, 33%가 실제로는 34%인 것을 확인했다.
지금까지 배운 것을 다 쓰는 예제
15편까지 배운 것들을 하나씩 따로 써봤는데, 실제 프로그램은 이것들이 섞여 있다. 화면을 이동하고, 상태를 들고 다니고, 무작위로 뭔가를 만들어내는 구조를 만들어봤다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
using System;
namespace JobPick
{
class Program
{
enum ClassType
{
None = 0,
Knight = 1,
Archer = 2,
Mage = 3
}
struct Player
{
public int hp;
public int attack;
//public ClassType type;
}
enum MonsterType
{
None = 0,
Slime = 1,
Orc = 2,
Skeleton = 3
}
struct Monster
{
public int hp;
public int attack;
//public ClassType type;
}
static ClassType ChooseClass()
{
Console.WriteLine("직업을 선택하세요!");
Console.WriteLine("[1] 기사");
Console.WriteLine("[2] 궁수");
Console.WriteLine("[3] 법사");
ClassType choice = ClassType.None;
string input = Console.ReadLine();
//if (input == "1" || input == "2" || input == "3")
// break;
switch (input)
{
case "1":
choice = ClassType.Knight;
break;
case "2":
choice = ClassType.Archer;
break;
case "3":
choice = ClassType.Mage;
break;
}
return choice;
}
static void CreatePlayer(ClassType choice, out Player player)
{
switch (choice)
{
case ClassType.Knight:
player.hp = 100;
player.attack = 10;
break;
case ClassType.Archer:
player.hp = 75;
player.attack = 12;
break;
case ClassType.Mage:
player.hp = 50;
player.attack = 15;
break;
default:
player.hp = 0;
player.attack = 0;
break;
}
}
static void CreateRandomMonster(out Monster monster)
{
Random rand = new Random();
int randMonster = rand.Next(1, 4);
switch (randMonster)
{
case (int)MonsterType.Slime:
Console.WriteLine("슬라임이 스폰되었습니다.");
monster.hp = 20;
monster.attack = 2;
break;
case (int)MonsterType.Orc:
Console.WriteLine("오크가 스폰되었습니다.");
monster.hp = 40;
monster.attack = 4;
break;
case (int)MonsterType.Skeleton:
Console.WriteLine("스켈레톤이 스폰되었습니다.");
monster.hp = 30;
monster.attack = 3;
break;
default:
monster.hp = 0;
monster.attack = 0;
break;
}
// 랜덤으로 1~3 몬스터 중 하나를 리스폰
}
static void Fight(ref Player player, ref Monster monster)
{
while (true)
{
// 플레이어가 몬스터 공격
monster.hp -= player.attack;
if (monster.hp <= 0)
{
Console.WriteLine("승리했습니다!");
Console.WriteLine($"남은 체력 : {player.hp}\n");
break;
}
// 몬스터 반격
player.hp -= monster.attack;
if (player.hp <= 0)
{
Console.WriteLine("패배했습니다!\n");
break;
}
}
}
static void EnterField(ref Player player)
{
while (true)
{
Console.WriteLine("필드에 접속했습니다!");
// 랜덤으로 1~3 몬스터 중 하나를 리스폰
Monster monster;
CreateRandomMonster(out monster);
Console.WriteLine("[1] 전투 모드로 돌입");
Console.WriteLine("[2] 일정 확률로 마을로 도망");
string input = Console.ReadLine();
if (input == "1")
{
Fight(ref player, ref monster); // 한명이 죽을때가지 끝장을 봄.
}
else if (input == "2")
{
// 33%
Random rand = new Random();
int randValue = rand.Next(0, 101);
if (randValue <= 33)
{
Console.WriteLine("도망치는데 성공했습니다!");
break;
}
else
{
Fight(ref player, ref monster);
}
}
}
}
static void EnterGame(ref Player player)
{
while (true)
{
Console.WriteLine("마을에 접속했습니다!");
Console.WriteLine("[1] 필드로 간다");
Console.WriteLine("[2] 로비로 돌아가기");
string input = Console.ReadLine();
if (input == "1")
{
EnterField(ref player);
}
else if (input == "2")
{
break;
}
//switch (input)
//{
// case "1":
// // EnterField();
// break;
// case "2":
// return;
//}
}
}
static void Main(string[] args)
{
while (true)
{
ClassType choice = ChooseClass();
if (choice == ClassType.None)
continue;
// 캐릭터 생성
// 기사(100/10), 궁수(75/12), 법사(50/15)
Player player;
CreatePlayer(choice, out player);
Console.WriteLine($"HP{player.hp} Attack{player.attack}\n");
EnterGame(ref player);
}
}
}
}
화면을 함수로 나누기
구조가 이렇게 되어 있다.
1
2
3
4
5
Main (로비)
└ EnterGame (마을)
└ EnterField (필드)
├ CreateRandomMonster
└ Fight
각 화면이 while (true) 루프 하나를 갖고, 안에서 메뉴를 보여주고 입력을 받는다. 다른 화면으로 가면 그 함수를 부르고, 돌아오려면 break로 자기 루프를 끝낸다.
함수 호출이 곧 화면 이동이고, 반환이 곧 뒤로 가기다. 호출 스택이 화면 스택 역할을 한다. 이 구조가 마음에 들었다. EnterGame에서 break하면 자동으로 Main의 로비로 돌아간다.
ref Player player가 계속 따라다니는 건 Player가 struct이기 때문이다. 17편에서 볼 내용인데, 값 형식이라 그냥 넘기면 복사본이 전달되어 체력이 깎여도 원본이 안 바뀐다. class로 만들었으면 ref 없이도 됐다.
죽어도 필드를 못 벗어난다
직접 돌려보고 제일 먼저 걸린 문제다. 패배하고 나서도 게임이 안 끝난다.
1
2
3
4
5
패배했습니다!
필드에 접속했습니다!
오크가 스폰되었습니다.
[1] 전투 모드로 돌입
Fight는 승패가 나면 break로 끝나는데, 그걸 부른 EnterField의 while (true)는 계속 돈다. 체력이 0 이하인 채로 새 몬스터가 스폰되고 다시 싸운다.
Fight 안에서는 break가 자기 루프만 끝낸다는 걸 9편에서 봤다. 함수 밖 루프까지 끝내려면 결과를 밖으로 알려줘야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
static void EnterField(ref Player player)
{
while (true)
{
// ...
if (input == "1")
{
Fight(ref player, ref monster);
if (player.hp <= 0) return; // 죽으면 필드를 나간다
}
}
}
EnterGame과 Main에도 같은 검사가 필요하다. 죽었는데 마을로 돌아가면 안 되니까. 상태를 확인하는 코드가 여러 곳에 흩어지는 게 마음에 안 들면, Fight가 결과를 돌려주게 하는 편이 낫다.
1
static bool Fight(ref Player player, ref Monster monster) // 살아남았으면 true
Random 을 함수 안에서 새로 만들면
CreateRandomMonster와 도망 판정에서 각각 new Random()을 한다.
1
2
3
4
static void CreateRandomMonster(out Monster monster)
{
Random rand = new Random(); // 부를 때마다 새로 만든다
int randMonster = rand.Next(1, 4);
Random은 시드값에서 수열을 만든다. 시드를 안 주면 시스템에서 알아서 가져오는데, 옛날 .NET Framework에서는 그게 시스템 시간(약 15 ms 해상도)이었다. 짧은 시간 안에 여러 개를 만들면 전부 같은 시드를 받아 같은 값을 낸다.
몬스터가 계속 같은 것만 나오거나, 도망 판정이 항상 같은 결과가 나오는 증상이 여기서 온다.
static 필드 하나로 두고 공유하는 게 정석이다.
1
static Random rand = new Random();
6편에서 하나만 만들어 재사용한 게 맞는 방식이었다.
최신 .NET에서는 Random.Shared가 있어서 직접 만들 필요도 없다. 스레드 안전하기까지 하다.
1
int randMonster = Random.Shared.Next(1, 4);
33% 가 아니라 34% 다
1
2
int randValue = rand.Next(0, 101);
if (randValue <= 33)
Next(0, 101)은 0부터 100까지 101개 값을 균등하게 준다. 그중 <= 33인 건 0, 1, …, 33으로 34개다. 확률은 34/101이라 약 33.7%다.
주석에는 33%라고 적혀 있는데 미묘하게 다르다. 게임 밸런스에 큰 영향은 없지만, 이런 경계 실수가 쌓이면 나중에 확률이 왜 안 맞는지 찾기 어려워진다.
정확히 33%로 하려면 이렇게 쓴다.
1
if (rand.Next(0, 100) < 33) // 0~99 중 0~32 = 33 개
Next(0, 100) < 33 형태가 “100 중 33”이 그대로 읽혀서 헷갈릴 여지가 적었다. 6편에서 본 “상한은 포함되지 않는다”를 여기서도 적용하면 된다.
enum 을 int 로 캐스팅하는 부분
1
2
3
4
int randMonster = rand.Next(1, 4);
switch (randMonster)
{
case (int)MonsterType.Slime:
randMonster가 int라서 case도 int여야 하고, 그래서 enum을 캐스팅했다. 동작은 하는데 enum을 쓰는 이점이 반쯤 사라진다.
먼저 캐스팅해두면 switch가 enum으로 깔끔해진다.
1
2
3
4
5
6
MonsterType type = (MonsterType)rand.Next(1, 4);
switch (type)
{
case MonsterType.Slime:
// ...
}
5편에서 적었듯이 enum은 정의에 없는 값도 가질 수 있으니 default는 그대로 두는 게 맞다.
잘못 입력하면 아무 말이 없다
EnterField에서 “3”을 입력하면 if와 else if 어디에도 안 걸린다. 그냥 루프가 다시 돌면서 새 몬스터가 스폰된다.
사용자 입장에서는 잘못 눌렀는데 몬스터가 바뀐다. 안내가 없으니 뭘 잘못했는지도 모른다.
1
2
3
4
5
else
{
Console.WriteLine("잘못된 입력입니다.");
continue;
}
그리고 몬스터 생성이 루프 안에 있는 것도 다시 보게 됐다. “필드에 들어가면 몬스터가 하나 나온다”가 의도라면 루프 밖이 맞고, “매번 새로 만난다”가 의도면 지금이 맞다. 어느 쪽인지 코드만 봐서는 알 수 없다.
프로그램을 끝낼 방법이 없다
Main의 while (true)에는 종료 조건이 없다. EnterGame에서 “로비로 돌아가기”를 골라도 다시 직업 선택으로 돌아간다.
콘솔 창을 닫는 것 말고는 끝낼 방법이 없다. 로비에 종료 항목을 하나 두는 게 자연스럽다.
1
2
3
Console.WriteLine("[0] 게임 종료");
// ...
if (input == "0") return;
out 보다 반환값
1
static void CreatePlayer(ClassType choice, out Player player)
값 하나를 만들어 주는 함수라 반환값이 자연스럽다.
1
2
3
4
5
6
7
8
9
10
static Player CreatePlayer(ClassType choice)
{
switch (choice)
{
case ClassType.Knight: return new Player { hp = 100, attack = 10 };
case ClassType.Archer: return new Player { hp = 75, attack = 12 };
case ClassType.Mage: return new Player { hp = 50, attack = 15 };
default: return new Player();
}
}
호출부도 짧아진다.
1
Player player = CreatePlayer(choice);
11편에서 적은 “out은 값을 여러 개 돌려줄 때”라는 기준에 비춰보면, 여기는 하나뿐이라 out을 쓸 이유가 없었다.
정리하면
- 화면을 함수로 나누면 호출 스택이 화면 스택이 된다. 함수 호출이 이동, 반환이 뒤로 가기다
break는 자기 루프만 끝낸다. 함수 안에서 끝난 이유를 밖의 루프가 알려면 결과를 돌려줘야 한다Random을 부를 때마다 새로 만들면 같은 값이 나올 수 있다. 하나를 공유하거나Random.Shared를 쓴다Next(0, 101)에서<= 33은 34개다. 확률을 정확히 하려면Next(0, 100) < 33enum은switch전에 캐스팅해두면case가 깔끔해진다- 처리하지 않는 입력에는 안내를 준다. 조용히 넘어가면 사용자가 원인을 모른다
- 값 하나를 만들어 주는 함수는
out보다 반환값이 낫다