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