Add copyright notices (GPL v2 or later).
[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 things
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         ct = 0
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                 ct = ct + 1
270
271         for row in self.grid:
272             for tile in row:
273                 self.activate(tile)
274         __main__.AppUI.instance.message("ct is now %s" % (ct,))
275     
276     def activate(self, tile):
277         (y, x) = (tile.y, tile.x)
278         if tile.is_active():
279             self.active_tiles.add((y, x))
280         if isinstance(tile, loc.Stair) and not tile.target() and not tile.oneway:
281             self.stair_available(tile)
282         return tile
283
284     def load_file(self, f):
285         attrs = {}
286         def parse_line(line, lno):
287             linere = r'^(\d+)\s+(\d+)\s+(\w+)\s+(\w+)\s+(.*?)\s*\n$'
288             m = re.match(linere, line)
289             (y, x, attr, typ, valstr) = m.groups()
290             y = int(y) ; x = int(x)
291             if not 0 <= y < self.h:
292                 raise Exception("extra out of bounds: y=%d" % (y))
293             if not 0 <= x < self.w:
294                 raise Exception("extra out of bounds: x=%d" % (x))
295
296             val = None
297             if typ == "float":
298                 val = float(valstr)
299             elif typ == "int":
300                 val = int(valstr)
301             elif typ == "bool":
302                 val = valstr != "False"
303             elif typ == "str":
304                 val = valstr.strip()
305             elif typ == "level":
306                 val = LevelProxy(valstr.strip())
307             elif typ == "Loc":
308                 val = LocationProxy(locstr = valstr.strip())
309             else:
310                 raise Exception("unknown type %s (val '%s')" % (typ, valstr))
311             if (y,x) not in attrs:
312                 attrs[(y,x)] = {}
313             attrs[(y,x)][attr] = val
314
315         line = f.readline() ; lno = 1
316         if line.rstrip('\n') != "#!RgLkLvL":
317             raise Exception("Bad first line: '%s'" % (line))
318
319         line = f.readline() ; lno = 2
320
321         (hs, ws, name) = re.split("\\s+", line, 2)
322         self.h = int(hs)
323         self.w = int(ws)
324         self.name = name.rstrip('\n')
325
326         self.grid = [[None] * self.w for x in xrange(self.h)]
327
328         lines = []
329
330         first_lno = lno + 1
331         for i in range(self.h):
332             lines.append(f.readline())
333             lno = lno + 1
334
335         for l in f.readlines():
336             lno = lno + 1
337             parse_line(l, lno)
338
339         for i in range(self.h):
340             line = lines[i]
341             if len(line) != self.w + 1:
342                 raise Exception("incorrect line length: line %d = %d"
343                         % (first_lno + i, len(line)))
344             for j in range(self.w):
345                 t = self.decode_tile(i, j, line[j],
346                         attrs[(i,j)] if (i,j) in attrs else {})
347                 self.grid[i][j] = self.activate(t)
348             if line[self.w] != '\n':
349                 raise Exception("line %d has no newline" % (i))
350
351
352         f.close()
353
354     def pick_tile_1(self, i, j):
355         if i == 15 and j == 60:
356             return loc.Stair(self, i, j)
357         elif (i-15)**2 + ((j-60)**2)/2 <= 36:
358             flowy = (j-60) / (math.sqrt(2) * 6) - (i-15)/(math.sqrt(2)*18)
359             flowx = (15-i) * math.sqrt(2) / 6 - (j-60)*math.sqrt(2)/18
360             return loc.Water(self, i, j, flowy, flowx)
361         elif 580 < (i-5)**2 + ((j-45)**2)/2 < 900:
362             flowy = (45-j) / (math.sqrt(2) * 27)
363             flowx = (i-5) * math.sqrt(2) / 27
364             return loc.Water(self, i, j, flowy, flowx)
365         elif random.random() < .1:
366             return loc.Wall(self, i, j)
367         else:
368             return loc.Floor(self, i, j, self.randitems())
369     
370     def pick_tile(self, i, j):
371         if i==0 or j==0 or i == self.h - 1 or j == self.w - 1:
372             return loc.Wall(self, i, j)
373         if (i == 5 and j == 9) or (i == self.h-5 and j == self.w - 10):
374             return loc.Stair(self, i, j)
375         if i % 9 == 0:
376             if (j == 9 and i % 18 == 0) or (j == self.w-10 and i % 18 == 9):
377                 return loc.Door(self, i, j)
378             else:
379                 return loc.Wall(self, i, j)
380         if j % 18 == 0:
381             if i % 9 == 4:
382                 return loc.Door(self, i, j)
383             else:
384                 return loc.Wall(self, i, j)
385         else:
386             return loc.Floor(self, i, j, self.randitems())
387
388     def get_target(self, oneway):
389         if oneway:
390             return self.unconnected_drops.pop()
391         else:
392             return self.unconnected_stairs.pop()
393
394     def invalidate(self, tile=None, important=False):
395         if tile == None:
396             self.needs_full_repaint = True
397         else:
398             assert tile.level() == self
399             self.invalid_tiles.add((tile.y, tile.x))
400             if important:
401                 self.important_repaints.add((tile.y, tile.x))
402
403     def decode_tile(self, i, j, char, attrs):
404         if char == "#":
405             return loc.Wall(self, i, j, **attrs)
406         elif char == " ":
407             return loc.Floor(self, i, j, self.randitems(), **attrs)
408         elif char == "~":
409             return loc.Water(self, i, j, **attrs)
410         elif char == "+":
411             return loc.Door(self, i, j, opened = False, **attrs)
412         elif char == "-":
413             return loc.Door(self, i, j, opened = True, **attrs)
414         elif char == ">":
415             return loc.Stair(self, i, j, **attrs)
416         elif char == "<":
417             # Target for one-way stairs
418             tile = loc.Floor(self, i, j, **attrs)
419             self.unconnected_drops.append(tile)
420             return tile
421         else:
422             raise Exception
423
424     def randitems(self):
425         r = 1000*random.random()
426         if r >= 10:
427             return []
428         elif r >= 5:
429             return [ things.ItemClass("gold piece")() ]
430         elif r >= 2:
431             return [ things.ItemClass("gem")() ]
432         elif r >= 1:
433             return [ things.ItemClass("shield")() ]
434         else:
435             return [ things.ItemClass("sword")() ]
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         (dy, dx) = (oy-py, ox-px)
456         if abs(dy) <= 1 and abs(dx) <= 1:
457             return True
458         flipped = False
459         # Go in the direction of the longer axis
460         if abs(dy) > abs(dx):
461             flipped = True
462             (dy, dx) = (dx, dy)
463             (py, px) = (px, py)
464             (oy, ox) = (ox, oy)
465
466         xinc = 1 if dx >= 0 else -1
467         yinc = 1 if dy >= 0 else -1
468         
469         einc = abs(float(dy)/dx)
470         err = einc - 1.0
471
472         y = py + (yinc if einc >= 0.5 else 0)
473
474         for x in xrange(px + xinc, ox, xinc):
475             (ly, lx) = (x, y) if flipped else (y, x)
476
477             if not self.loc(ly, lx).transparent():
478                 return False
479
480             err += einc
481             if err >= 0.5:
482                 y += yinc
483                 err -= 1.0
484
485         return True
486
487     # TODO: use dynamic programming: if we can't see a near tile, there is
488     # no reason to test the tiles behind it.
489     def draw(self):
490         if self.needs_full_repaint:
491             self.invalid_tiles = set((y,x) for y in range(self.h) for x in range(self.w))
492             self.needs_full_repaint = False
493         vis = set()
494         
495         for pl in self.players():
496             vis |= set(pl.visibles())
497
498         vis &= (self.invalid_tiles | self.active_tiles)
499         vis |= self.important_repaints
500         for (y,x) in vis:
501             (char, color) = self.grid[y][x].render()
502             self.boardwin.addstr(y, x, char, color)
503         self.invalid_tiles -= vis
504         self.important_repaints.clear()
505
506     def export(self):
507         extras = {}
508         f = StringIO()
509         f.write("#!RgLkLvL\n")
510         f.write("%d %d %s\n" % (self.h, self.w, self.name))
511         for i in range(self.h):
512             for j in range(self.w):
513                 tile = self.grid[i][j]
514                 (s, extra) = tile.export()
515                 f.write(s)
516                 if len(extra) > 0:
517                     extras[(i,j)] = extra
518             f.write("\n")
519         for ((y, x), ex) in extras.iteritems():
520             for (k,v) in ex.iteritems():
521                 f.write("%d %d %s %s %s\n" % (y, x, k, type(v).__name__, v))
522         val = f.getvalue()
523         f.close()
524         return val
525
526     def level(self):
527         return self
528
529     def affect_all(self):
530         for (y,x) in self.active_tiles:
531             self.grid[y][x].affect_all()
532
533
534 class LevelProxy (object):
535     def __init__(self, lid):
536         self.lid = lid
537         self.lvl = None
538
539     def level(self):
540         if not self.lvl:
541             self.lvl = Level(self.lid)
542         return self.lvl
543