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