adb4c6b93bd9b721faa6620d74487cd3d268e38b
[roguelike.git] / level.py
1 #! /usr/bin/env python
2 # -*- coding: utf-8 -*-
3
4 # Copyright © 2008 Neil Moore <neil@s-z.org>.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15 #  
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
19
20 import curses
21 import random
22 import math
23 import re
24 from cStringIO import StringIO
25
26 import loc
27 import thing
28 import cacher
29 import __main__
30
31 class Loc (object):
32     def __init__(self, lid, y, x):
33
34         self.level = lid
35         self.y = y
36         self.x = x
37
38     def __str__(self):
39         return "%s %d %d" % (self.level, self.y, self.x)
40
41
42 class LocationProxy (object):
43     def __init__(self, level=None, y=None, x=None, locstr=None):
44         if locstr:
45             (lstr, ystr, xstr) = locstr.split(None, 2)
46             level = LevelProxy(lstr)
47             y = int(ystr)
48             x = int(xstr)
49         assert (level and y is not None and x is not None)
50         self.lvl = level
51         self.y = y
52         self.x = x
53
54     def as_loc(self):
55         return Loc(self.lvl.lid, self.y, self.x)
56
57     def level(self):
58         if isinstance(self.lvl, LevelProxy):
59             self.lvl = self.lvl.level()
60         return self.lvl
61
62     def location(self):
63         return self.level().loc(self.y, self.x)
64     
65 class Room (object):
66     """A room, with a union-find structure for connected rooms."""
67     def __init__(self, y, x, h, w):
68         (self.y, self.x, self.h, self.w) = (y, x, h, w)
69         self.ymax = y + h - 1
70         self.xmax = x + w - 1
71         self.doors = { 't': False, 'b': False, 'l': False, 'r': False }
72
73     def facing(self, room):
74         possible = []
75         assert not (self & room), "Can't face intersecting room"
76
77         if self.y > room.ymax:
78             dy = room.ymax - self.y
79         elif room.y > self.ymax:
80             dy = room.y - self.ymax
81         else:
82             dy = 0
83         
84         if self.x > room.xmax:
85             dx = room.xmax - self.x
86         elif room.x > self.xmax:
87             dx = room.x - self.xmax
88         else:
89             dx = 0
90
91         if abs(dx) > abs(dy):
92             return 'r' if dx > 0 else 'l'
93         else:
94             return 'b' if dy > 0 else 't'
95
96     def add_door(self, face):
97         if face == 't':
98             dx = random.randint(self.x+1, self.xmax-1)
99             dy = self.y
100         elif face == 'b':
101             dx = random.randint(self.x+1, self.xmax-1)
102             dy = self.ymax
103         elif face == 'l':
104             dx = self.x
105             dy = random.randint(self.y+1, self.ymax-1)
106         elif face == 'r':
107             dx = self.xmax
108             dy = random.randint(self.y+1, self.ymax-1)
109         else:
110             raise Exception("Unknown face %s" % (face,))
111
112         return (dy, dx)
113
114     def __and__(self, room):
115         yoverlap = (self.y <= room.ymax+1 and self.ymax >= room.y-1)
116         xoverlap = (self.x <= room.xmax+1 and self.xmax >= room.x-1)
117         return yoverlap and xoverlap
118
119
120 class Level (object):
121     """Represents an area of the map comprising a contiguous rectangular
122     region of Tiles surrounded by an impassable boundary.  Levels may
123     be travelled between via transport points.
124     """
125     __metaclass__ = cacher.FirstArg
126
127     gen_count = 0
128
129     @classmethod
130     def generate_id(cls):
131         cls.gen_count = cls.gen_count + 1
132         return 'gen%d' % (cls.gen_count,)
133
134     def __init__(self, lid=None, height=None, width=None, name=None):
135         self.active_tiles = set()
136         self.invalid_tiles = set()
137         self.important_repaints = set()
138         self.unconnected_stairs = set()
139         self.unconnected_drops = []
140         self.needs_full_repaint = True
141
142         if lid:
143             self.lid = lid
144             self.filename = "levels/%s.sav" % (lid,)
145             assert height == width == name == None, \
146                     "Level constructor specified more than just the filename"
147             self.load_file(open(self.filename, "r"))
148         else:
149             self.lid = self.generate_id()
150             self.filename = None
151             assert height and width
152             self.h = height
153             self.w = width
154             self.name = name or ""
155
156             if random.random() < 0.1:
157                 self.pick_tile = random.choice(
158                         (self.pick_tile_1, self.pick_tile))
159
160                 self.grid = [ [ self.activate(self.pick_tile(i,j))
161                     for j in xrange(self.w) ]
162                     for i in xrange(self.h) ]
163             else:
164                 self.make_room_map()
165
166         random.shuffle(self.unconnected_drops)
167
168         self.boardwin = curses.newwin(self.h + 1, self.w + 1)
169
170     def stair_unavailable(self, stair):
171         if stair in self.unconnected_stairs:
172             self.unconnected_stairs.remove(stair)
173     def stair_available(self, stair):
174         self.unconnected_stairs.add(stair)
175
176
177     def connect(self, rm1, rm2):
178         # pick nearer sides of each room
179         faces = (rm1.facing(rm2), rm2.facing(rm1))
180         (y1,x1) = rm1.add_door(faces[0])
181         (y2,x2) = rm2.add_door(faces[1])
182         
183         self.grid[y1][x1] = loc.Door(self, y1, x1)
184         self.grid[y2][x2] = loc.Door(self, y2, x2)
185
186         dy = y2 - y1
187         dx = x2 - x1
188
189         if faces[0] == 'r' and faces[1] == 'l':
190             # >V> or >^>
191             ydir = dy/abs(dy) if dy else 0
192             ty = y1
193             for tx in xrange(x1+1, x2):
194                 self.grid[ty][tx] = loc.Floor(self, ty, tx)
195                 if tx == (x1 + x2) // 2 and dy != 0:
196                     while ty != y2:
197                         self.grid[ty][tx] = loc.Floor(self, ty, tx)
198                         ty = ty + ydir
199                     self.grid[ty][tx] = loc.Floor(self, ty,tx)
200         elif faces[0] == 'l' and faces[1] == 'r':
201             # >V> or >^>
202             ydir = -dy/abs(dy) if dy else 0
203             ty = y2
204             for tx in xrange(x2+1, x1):
205                 self.grid[ty][tx] = loc.Floor(self, ty, tx)
206                 if tx == (x1 + x2) // 2 and dy != 0:
207                     while ty != y1:
208                         self.grid[ty][tx] = loc.Floor(self, ty, tx)
209                         ty = ty + ydir
210                     self.grid[ty][tx] = loc.Floor(self, ty,tx)
211         elif faces[0] == 'b' and faces[1] == 't':
212             # V>V or V<V
213             xdir = dx/abs(dx) if dx else 0
214             tx = x1
215             for ty in xrange(y1+1, y2):
216                 self.grid[ty][tx] = loc.Floor(self, ty, tx)
217                 if ty == (y1 + y2) // 2 and dx != 0:
218                     while tx != x2:
219                         self.grid[ty][tx] = loc.Floor(self, ty, tx)
220                         tx = tx + xdir
221                     self.grid[ty][tx] = loc.Floor(self, ty,tx)
222         elif faces[0] == 't' and faces[1] == 'b':
223             # ^>^ or ^<^
224             xdir = -dx/abs(dx) if dx else 0
225             tx = x2
226             for ty in xrange(y2+1, y1):
227                 self.grid[ty][tx] = loc.Floor(self, ty, tx)
228                 if ty == (y1 + y2) // 2 and dx != 0:
229                     while tx != x1:
230                         self.grid[ty][tx] = loc.Floor(self, ty, tx)
231                         tx = tx + xdir
232                     self.grid[ty][tx] = loc.Floor(self, ty,tx)
233
234     def make_room_map(self, nrooms = 10, nstairs = 2):
235         self.grid = [ [ loc.Wall(self,i,j)
236             for j in xrange(self.w) ]
237             for i in xrange(self.h) ]
238         rooms = []
239
240         st_rooms = set(random.sample(xrange(nrooms), nstairs))
241
242         for rno in xrange(nrooms):
243             rm = None
244
245             while not rm or any(orm & rm for orm in rooms):
246                 rh = random.randint(4, self.h // (math.sqrt(nrooms)/1.5))
247                 rw = random.randint(4, self.w // (math.sqrt(nrooms)/1.5))
248                 ry = random.randint(0, self.h - rh)
249                 rx = random.randint(0, self.w - rw)
250                 rm = Room(ry, rx, rh, rw)
251
252             wet = random.random() < 0.1
253
254             rooms.append(rm)
255             for i in xrange(rm.y+1, rm.ymax):
256                 for j in xrange(rm.x+1, rm.xmax):
257                     if rno in st_rooms and i == ry + rh//2 and j == rx + rw//2:
258                         self.grid[i][j] = loc.Stair(self, i, j)
259                     elif wet:
260                         self.grid[i][j] = loc.Water(self, i, j)
261                     else:
262                         self.grid[i][j] = loc.Floor(self, i, j)
263
264         for rno in xrange(1,nrooms):
265             nct = 1 if rno == 1 or random.random() < 0.8 else 2
266             for tgt in random.sample(range(0, rno), nct):
267                 self.connect(rooms[rno], rooms[tgt])
268
269         for row in self.grid:
270             for tile in row:
271                 self.activate(tile)
272     
273     def activate(self, tile):
274         (y, x) = (tile.y, tile.x)
275         if tile.is_active():
276             self.active_tiles.add((y, x))
277         if isinstance(tile, loc.Stair) and not tile.target() and not tile.oneway:
278             self.stair_available(tile)
279         return tile
280
281     def load_file(self, f):
282         attrs = {}
283         def parse_line(line, lno):
284             linere = r'^(\d+)\s+(\d+)\s+(\w+)\s+(\w+)\s+(.*?)\s*\n$'
285             m = re.match(linere, line)
286             (y, x, attr, typ, valstr) = m.groups()
287             y = int(y) ; x = int(x)
288             if not 0 <= y < self.h:
289                 raise Exception("extra out of bounds: y=%d" % (y))
290             if not 0 <= x < self.w:
291                 raise Exception("extra out of bounds: x=%d" % (x))
292
293             val = None
294             if typ == "float":
295                 val = float(valstr)
296             elif typ == "int":
297                 val = int(valstr)
298             elif typ == "bool":
299                 val = valstr != "False"
300             elif typ == "str":
301                 val = valstr.strip()
302             elif typ == "level":
303                 val = LevelProxy(valstr.strip())
304             elif typ == "Loc":
305                 val = LocationProxy(locstr = valstr.strip())
306             else:
307                 raise Exception("unknown type %s (val '%s')" % (typ, valstr))
308             if (y,x) not in attrs:
309                 attrs[(y,x)] = {}
310             attrs[(y,x)][attr] = val
311
312         line = f.readline() ; lno = 1
313         if line.rstrip('\n') != "#!RgLkLvL":
314             raise Exception("Bad first line: '%s'" % (line))
315
316         line = f.readline() ; lno = 2
317
318         (hs, ws, name) = re.split("\\s+", line, 2)
319         self.h = int(hs)
320         self.w = int(ws)
321         self.name = name.rstrip('\n')
322
323         self.grid = [[None] * self.w for x in xrange(self.h)]
324
325         lines = []
326
327         first_lno = lno + 1
328         for i in range(self.h):
329             lines.append(f.readline())
330             lno = lno + 1
331
332         for l in f.readlines():
333             lno = lno + 1
334             parse_line(l, lno)
335
336         for i in range(self.h):
337             line = lines[i]
338             if len(line) != self.w + 1:
339                 raise Exception("incorrect line length: line %d = %d"
340                         % (first_lno + i, len(line)))
341             for j in range(self.w):
342                 t = self.decode_tile(i, j, line[j],
343                         attrs[(i,j)] if (i,j) in attrs else {})
344                 self.grid[i][j] = self.activate(t)
345             if line[self.w] != '\n':
346                 raise Exception("line %d has no newline" % (i))
347
348
349         f.close()
350
351     def pick_tile_1(self, i, j):
352         if i == 15 and j == 60:
353             return loc.Stair(self, i, j)
354         elif (i-15)**2 + ((j-60)**2)/2 <= 36:
355             flowy = (j-60) / (math.sqrt(2) * 6) - (i-15)/(math.sqrt(2)*18)
356             flowx = (15-i) * math.sqrt(2) / 6 - (j-60)*math.sqrt(2)/18
357             return loc.Water(self, i, j, flowy, flowx)
358         elif 580 < (i-5)**2 + ((j-45)**2)/2 < 900:
359             flowy = (45-j) / (math.sqrt(2) * 27)
360             flowx = (i-5) * math.sqrt(2) / 27
361             return loc.Water(self, i, j, flowy, flowx)
362         elif random.random() < .1:
363             return loc.Wall(self, i, j)
364         else:
365             return loc.Floor(self, i, j, self.randitems())
366     
367     def pick_tile(self, i, j):
368         if i==0 or j==0 or i == self.h - 1 or j == self.w - 1:
369             return loc.Wall(self, i, j)
370         if (i == 5 and j == 9) or (i == self.h-5 and j == self.w - 10):
371             return loc.Stair(self, i, j)
372         if i % 9 == 0:
373             if (j == 9 and i % 18 == 0) or (j == self.w-10 and i % 18 == 9):
374                 return loc.Door(self, i, j)
375             else:
376                 return loc.Wall(self, i, j)
377         if j % 18 == 0:
378             if i % 9 == 4:
379                 return loc.Door(self, i, j)
380             else:
381                 return loc.Wall(self, i, j)
382         else:
383             return loc.Floor(self, i, j, self.randitems())
384
385     def get_target(self, oneway):
386         if oneway:
387             return self.unconnected_drops.pop()
388         else:
389             return self.unconnected_stairs.pop()
390
391     def invalidate(self, tile=None, important=False):
392         if tile == None:
393             self.needs_full_repaint = True
394         else:
395             assert tile.level() == self
396             self.invalid_tiles.add((tile.y, tile.x))
397             if important:
398                 self.important_repaints.add((tile.y, tile.x))
399
400     def decode_tile(self, i, j, char, attrs):
401         if char == "#":
402             return loc.Wall(self, i, j, **attrs)
403         elif char == " ":
404             return loc.Floor(self, i, j, self.randitems(), **attrs)
405         elif char == "~":
406             return loc.Water(self, i, j, **attrs)
407         elif char == "+":
408             return loc.Door(self, i, j, opened = False, **attrs)
409         elif char == "-":
410             return loc.Door(self, i, j, opened = True, **attrs)
411         elif char == ">":
412             return loc.Stair(self, i, j, **attrs)
413         elif char == "<":
414             # Target for one-way stairs
415             tile = loc.Floor(self, i, j, **attrs)
416             self.unconnected_drops.append(tile)
417             return tile
418         else:
419             raise Exception
420
421     def randitems(self):
422         r = 10000*random.random()
423         if r >= 100:
424             return []
425         elif r >= 50:
426             return [ thing.ItemClass("gold piece")() ]
427         elif r >= 20:
428             return [ thing.ItemClass("gem")() ]
429         elif r >= 10:
430             return [ thing.ItemClass("sword")() ]
431         elif r >= 2:
432             return [ thing.ItemClass("shield")() ]
433         else:
434             return [ thing.ItemClass("mask of water breathing")() ]
435
436     def loc(self, i, j):
437         return self.grid[i][j]
438
439     def clip(self, y, x):
440         if y<0:
441             y = 0
442         elif y >= self.h:
443             y = self.h-1
444         if x<0:
445             x = 0
446         elif x >= self.w:
447             x = self.w-1
448         return (y, x)
449
450     def players(self):
451         return (pl for pl in __main__.AppUI.instance.players if pl.level() == self)
452
453     def visible_path(self, py, px, oy, ox):
454         (dy, dx) = (oy-py, ox-px)
455         if abs(dy) <= 1 and abs(dx) <= 1:
456             return True
457         flipped = False
458         # Go in the direction of the longer axis
459         if abs(dy) > abs(dx):
460             flipped = True
461             (dy, dx) = (dx, dy)
462             (py, px) = (px, py)
463             (oy, ox) = (ox, oy)
464
465         xinc = 1 if dx >= 0 else -1
466         yinc = 1 if dy >= 0 else -1
467         
468         einc = abs(float(dy)/dx)
469         err = einc - 1.0
470
471         y = py + (yinc if einc >= 0.5 else 0)
472
473         for x in xrange(px + xinc, ox, xinc):
474             (ly, lx) = (x, y) if flipped else (y, x)
475
476             if not self.loc(ly, lx).transparent():
477                 return False
478
479             err += einc
480             if err >= 0.5:
481                 y += yinc
482                 err -= 1.0
483
484         return True
485
486     # TODO: use dynamic programming: if we can't see a near tile, there is
487     # no reason to test the tiles behind it.
488     def draw(self):
489         if self.needs_full_repaint:
490             self.invalid_tiles = set((y,x) for y in range(self.h) for x in range(self.w))
491             self.needs_full_repaint = False
492         vis = set()
493         
494         for pl in self.players():
495             vis |= set(pl.visibles())
496
497         vis &= (self.invalid_tiles | self.active_tiles)
498         vis |= self.important_repaints
499         for (y,x) in vis:
500             (char, color) = self.grid[y][x].render()
501             self.boardwin.addstr(y, x, char, color)
502         self.invalid_tiles -= vis
503         self.important_repaints.clear()
504
505     def export(self):
506         extras = {}
507         f = StringIO()
508         f.write("#!RgLkLvL\n")
509         f.write("%d %d %s\n" % (self.h, self.w, self.name))
510         for i in range(self.h):
511             for j in range(self.w):
512                 tile = self.grid[i][j]
513                 (s, extra) = tile.export()
514                 f.write(s)
515                 if len(extra) > 0:
516                     extras[(i,j)] = extra
517             f.write("\n")
518         for ((y, x), ex) in extras.iteritems():
519             for (k,v) in ex.iteritems():
520                 f.write("%d %d %s %s %s\n" % (y, x, k, type(v).__name__, v))
521         val = f.getvalue()
522         f.close()
523         return val
524
525     def level(self):
526         return self
527
528     def creatures(self):
529         return [ creat 
530                 for y in range(self.h)
531                 for x in range(self.w)
532                 for creat in self.loc(y,x)._creatures ]
533
534     def affect_all(self):
535         for (y,x) in self.active_tiles:
536             self.grid[y][x].affect_all()
537
538
539 class LevelProxy (object):
540     def __init__(self, lid):
541         self.lid = lid
542         self.lvl = None
543
544     def level(self):
545         if not self.lvl:
546             self.lvl = Level(self.lid)
547         return self.lvl
548