-
Notifications
You must be signed in to change notification settings - Fork 0
/
dungeon.py
447 lines (356 loc) · 11.9 KB
/
dungeon.py
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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
#! /usr/bin/env python3
# The executable class
# To run, use 'python3 dungeon.py'
import collections
import copy
import random
import sys
import termios
import tty
import character
from text import CLEAR
import text
#
# TODO:
# Create a generic (composition) interface.
# Make a generic container thing - it allows us to pass in functions
# with similar purposes but different algorithms.
# Just focus on making the character and the enemy work right now;
# these two kinds of people should stem from the same
# type called "character".
# Think about it this way - enemies and the hero are both human,
# but how do they differ? An enemy may be demonic. A hero
# may have a cape.
#
#
# Dimensions of the dungeon
X_DIM = 80
Y_DIM = 20
# Min and Max number of rooms per floor
NUM_ROOMS = (3, 5)
# Min and Max height and width of a room
ROOM_HEIGHT = (5, 8)
ROOM_WIDTH = (5, 20)
# Minimum separation between rooms
MIN_SEP = 2
TRAP_PROB = 0.5
MONSTER_PROB = 0.9
REGEN_PROB = 0.05 # 1/20 chance
MOVEABLE = ['.', '+', '#', '>', '<']
Room = collections.namedtuple('Room', 'x y width height')
Point = collections.namedtuple('Point', 'x y')
MONSTERS = [
('kiwi', 'k', 2, 1),
('goblin', 'g', 10, 1),
('panda', 'P', 40, 1),
]
class Monster:
def __init__(self, pos, name, what, hp, dmg):
self.pos = pos
self.name = name
self.what = what
self.hp = hp
self.dmg = dmg
self.old = '.' # monsters always spawn on a '.'
def move(self, level, newpos):
level[self.pos.x][self.pos.y] = self.old
self.old = level[newpos.x][newpos.y]
level[newpos.x][newpos.y] = 'm'
self.pos = newpos
def die(self, level):
level[self.pos.x][self.pos.y] = self.old
def random_door(level, room):
'''
Picks a random side for a door in and out of a room.
'''
deltax = deltay = 0
# Pick random side on room
side = random.randint(0, 3)
if side == 0 or side == 2:
deltay = random.randint(1, room.height-1)
elif side == 1 or side == 3:
deltax = random.randint(1, room.width-1)
if side == 1:
deltay = room.height
elif side == 2:
deltax = room.width
return Point(room.x + deltax, room.y + deltay)
def fill_room(level, room):
'''
Fill in a new room in the level, drawing borders around the room and
periods inside the room. Returns a copy of the level with the new room
added if the room did not collide with an existing room. Returns None if
there was a collision.
'''
new_level = copy.deepcopy(level)
# Populate new_level with room
for j in range(room.height+1):
for i in range(room.width+1):
# Check if there's already a room here
if level[room.x+i][room.y+j] != None:
return None
if j == 0 or j == room.height:
new_level[room.x+i][room.y+j] = '-'
elif i == 0 or i == room.width:
new_level[room.x+i][room.y+j] = '|'
else:
new_level[room.x+i][room.y+j] = '.'
# Ensure MIN_SEP space exists to left and right
for j in range(room.height+1):
if level[room.x-MIN_SEP][room.y+j] != None:
return None
if level[room.x+room.width+MIN_SEP][room.y+j] != None:
return None
# Ensure MIN_SEP space exists above and below
for i in range(room.width+1):
if level[room.x+i][room.y-MIN_SEP] != None:
return None
if level[room.x+i][room.y+room.height+MIN_SEP] != None:
return None
return new_level
def dist(p0, p1):
'''
Compute the euclidean distance between two points
'''
return ((p0.x - p1.x)**2 + (p0.y - p1.y)**2)**0.5
def dxdy(p):
'''
Yield the locations around the position to the left, right, above, and
below.
'''
for (dx, dy) in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
yield Point(p.x+dx, p.y+dy)
def create_path(level, p0, p1):
'''
Connect two points on the map with a path.
'''
# Compute all possible directions from here
points = []
for p in dxdy(p0):
if p == p1:
return True
if p.x >= X_DIM or p.x < 0:
continue
if p.y >= Y_DIM or p.y < 0:
continue
if level[p.x][p.y] not in [None, '#']:
continue
points.append(p)
# Sort points according to distance from p1
points.sort(key=lambda i: dist(i, p1))
for p in points:
old, level[p.x][p.y] = level[p.x][p.y], '$'
if create_path(level, p, p1):
level[p.x][p.y] = '#'
return True
level[p.x][p.y] = old
return False
def add_to_room(level, room, what):
'''
Pick a random open location in the room and add what at the location.
'''
points = []
for j in range(1, room.height):
for i in range(1, room.width):
if level[room.x+i][room.y+j] == '.':
points.append(Point(room.x+i, room.y+j))
if len(points) == 0:
return None
p = random.choice(points)
level[p.x][p.y] = what
return p
def make_level():
'''
Create a X_DIM by Y_DIM 2-D list filled with a random assortment of rooms.
'''
level = []
for i in range(X_DIM):
level.append([None] * Y_DIM)
monsters = []
rooms = []
# Randomly N generate room in level
for i in range(random.randint(*NUM_ROOMS)):
# Keep looking, there should be *somewhere* to put this room...
while True:
# Generate random room
x = random.randint(MIN_SEP, X_DIM)
y = random.randint(MIN_SEP, Y_DIM)
height = random.randint(*ROOM_HEIGHT)
width = random.randint(*ROOM_WIDTH)
# Check map boundary
if x + width + MIN_SEP >= X_DIM:
continue
if y + height + MIN_SEP >= Y_DIM:
continue
room = Room(x, y, width, height)
new_level = fill_room(level, room)
if not new_level:
continue
level = new_level
rooms.append(room)
# Check whether we should add a trap to this room
if random.random() < TRAP_PROB:
add_to_room(level, room, 'x')
# Check whether we should add a monster to this room
if random.random() < MONSTER_PROB:
p = add_to_room(level, room, 'm')
if p:
m = MONSTERS[random.randrange(len(MONSTERS))]
monsters.append(Monster(p, *m))
break
# Connect the rooms with random paths
for i in range(len(rooms)-1):
# Pick two random doors
door0 = random_door(level, rooms[i])
door1 = random_door(level, rooms[i+1])
level[door0.x][door0.y] = '+'
level[door1.x][door1.y] = '+'
# Actually connect them
if not create_path(level, door0, door1):
# TODO: Could happen... what should we do?
pass
# Pick random room for stairs leading up and down
up, down = random.sample(rooms, 2)
add_to_room(level, up, '<')
add_to_room(level, down, '>')
return level, monsters
def find_staircase(level, staircase):
'''
Scan the level to determine where a particular staircase is
'''
for j in range(Y_DIM):
for i in range(X_DIM):
if level[i][j] == staircase:
return Point(i, j)
return None
def print_level(level, monsters):
'''
Print the level using spaces when a tile isn't set
'''
for j in range(Y_DIM):
for i in range(X_DIM):
if level[i][j] == None:
sys.stdout.write(' ')
elif level[i][j] == 'x':
# It's a trap!
sys.stdout.write('.')
elif level[i][j] == 'm':
for m in monsters:
if m.pos.x == i and m.pos.y == j:
sys.stdout.write(m.what)
break
else:
sys.stdout.write(level[i][j])
sys.stdout.write('\n')
def read_key():
'''
Read a single key from stdin
'''
try:
fd = sys.stdin.fileno()
tty_settings = termios.tcgetattr(fd)
tty.setraw(fd)
key = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, tty_settings)
return key
if __name__ == '__main__':
# Initialize the first level
mc = character.Hero("Max", 0, 0, 0, 0)
levels = []
monsters = []
current = 0
res = make_level()
levels.append(res[0])
monsters.append(res[1])
pos = find_staircase(levels[current], '<')
curhp = maxhp = 10
while True:
wait = False
# Clear the terminal
sys.stdout.write(CLEAR)
if curhp <= 0:
sys.stdout.write('You died. gg\n')
break
level = levels[current]
# Swap in an '@' character in the position of the character, print the
# level, and then swap back
old, level[pos.x][pos.y] = level[pos.x][pos.y], '@'
print_level(level, monsters[current])
level[pos.x][pos.y] = old
sys.stdout.write('Health: {}/{}\n'.format(curhp, maxhp))
key = read_key()
if key == 'q':
break
elif key == '.':
newpos = pos
elif key == 'h':
newpos = Point(pos.x-1, pos.y)
elif key == 'j':
newpos = Point(pos.x, pos.y+1)
elif key == 'k':
newpos = Point(pos.x, pos.y-1)
elif key == 'l':
newpos = Point(pos.x+1, pos.y)
elif key == '>':
if level[newpos.x][newpos.y] == '>':
# Moving down a level
if current == len(levels) - 1:
res = make_level()
levels.append(res[0])
monsters.append(res[1])
current += 1
pos = find_staircase(levels[current], '<')
continue
elif key == '<':
if level[newpos.x][newpos.y] == '<':
# Moving up a level
if current > 0:
current -= 1
pos = find_staircase(levels[current], '>')
continue
else:
continue
if level[newpos.x][newpos.y] in ['x', 'o']:
# Walked onto a trap, reveal the trap and hurt the player
level[newpos.x][newpos.y] = 'o'
curhp -= 1
wait = False
sys.stdout.write('Ouch, it\' a trap!\n')
elif level[newpos.x][newpos.y] == 'm':
# Walked into a monster, attack!
for m in monsters[current]:
if m.pos == newpos:
m.hp -= 2
newpos = pos
elif level[newpos.x][newpos.y] not in MOVEABLE:
# Hit a wall, should stay put
newpos = pos
pos = newpos
# Random chance to regen some health
if random.random() < REGEN_PROB:
curhp += 1
curhp = min(curhp, maxhp)
# Update the monsters
for m in monsters[current]:
if m.hp <= 0:
m.die(level)
monsters[current].remove(m)
sys.stdout.write('You\'ve done it, you killed a {}!\n'.format(m.name))
wait = True
continue
d0 = dist(pos, m.pos)
if d0 < 15:
# Move the monster towards the player
for p in dxdy(m.pos):
d1 = dist(pos, p)
if pos == p:
# Monster moves into player, attack!
curhp -= m.dmg
elif level[p.x][p.y] in MOVEABLE and d1 < d0:
m.move(level, p)
break
# See if we should wait before redrawing the level
if wait:
sys.stdout.flush()
key = read_key()