Browse files

Split up the DungeonFloor object some more.

Now it's a Map, which is explicitly built to be stupid and only care
about placement and geometry.  The main interface to a Map is a Tile,
but that's now generated kinda lazily and just examines the Map's
internals.

Map creation is done by a Fractor, keeping all that hard-coded test junk
out of the rest of the code.

Fixed a lot of API awkwardness here, I think, so huzzah.
  • Loading branch information...
1 parent 660cb35 commit a1a6ea363da768a81253ec37d0c44ab90cb2b31a @eevee committed Jul 26, 2011
Showing with 313 additions and 192 deletions.
  1. +1 −1 raidne/exceptions.py
  2. +32 −174 raidne/game/dungeon.py
  3. +70 −0 raidne/game/fractor.py
  4. +168 −0 raidne/game/map.py
  5. +29 −10 raidne/ui/console/__init__.py
  6. +13 −7 raidne/util.py
View
2 raidne/exceptions.py
@@ -1,4 +1,4 @@
"""Exceptions."""
-class CollisionException(Exception):
+class CollisionError(Exception):
message = "two mutually-exclusive things tried to occupy the same space"
View
206 raidne/game/dungeon.py
@@ -1,174 +1,26 @@
-"""Core entry point for interacting with the game.
-
-A DungeonLevel represents where the player is, and is what's shown on the
-screen. This is the interface for most of the player's actions, as the player
-is always a Thing on the current dungeon level. A Dungeon is a container for
-multiple DungeonLevels, and is responsible for generating, saving, and loading
-them as the player progresses.
+"""The Dungeon is a container for multiple dungeon floors, and is responsible
+for generating, saving, and loading them as the player progresses, as well as
+the interaction between the player and the game world.
"""
-
from raidne import exceptions
from raidne.game import things
-from raidne.game.things import Wall, Floor, StaircaseDown, Player
-from raidne.util import Offset, Position, Size
-
-class DungeonTile(object):
- """Represents one grid tile in the dungeon and remembers what exists here.
-
- Must contain one tile of architecture. May contain any number of items,
- and up to one creature.
- """
- __slots__ = ('architecture', 'items', 'creature')
-
- # TODO make a void object to use as the default architecture
- def __init__(self, architecture, items=None, creature=None):
- self.architecture = architecture
- self.items = items or []
- self.creature = creature
-
- def __iter__(self):
- """Iteration support; yields every Thing on this tile, in z-order from
- top to bottom.
- """
- if self.creature:
- yield self.creature
- for item in self.items:
- yield item
- if self.architecture:
- yield self.architecture
-
- @property
- def topmost_thing(self):
- """Returns the topmost Thing positioned on this tile."""
- return next(iter(self))
-
- # TODO these probably shouldn't be publicly accessible
- def add(self, thing):
- if isinstance(thing, things.Creature):
- if self.creature:
- # XXX need a new exception hierarchy...
- raise TypeError("Can't have multiple creatures in one tile")
- self.creature = thing
- else:
- self.items.append(thing)
-
- def remove(self, thing):
- if thing is self.creature:
- self.creature = None
- else:
- # Throws KeyError if thing isn't here
- self.items.remove(thing)
-
-
-class DungeonFloor(object):
- """A single floor in the dungeon. This is where most of the interaction
- within the game world occurs.
- """
- def __init__(self):
- self.size = Size(10, 10)
- self.tiles = [
- [DungeonTile(Wall()) for col in xrange(self.size.cols)]
- for row in xrange(self.size.rows)
- ]
- self._thing_positions = {}
-
- # Build a little test room...
- for row in range(1, self.size.rows - 1):
- for col in range(1, self.size.cols - 1):
- if row == col == 4:
- continue
- self.tiles[row][col].architecture = Floor()
-
- # Make some potions
- for col in range(4, 7):
- self._place_thing(things.Potion(), Position(2, col))
-
- # n.b.: There's deliberately no __setitem__, as the tile at any given
- # position has no reason to ever be overwritten.
- def __getitem__(self, slice):
- try:
- row, col = slice
- return self.tiles[row][col]
- except (ValueError, TypeError):
- raise KeyError("DungeonLevel keys must be Position objects or tuples")
-
- def enumerate(self):
- """Iterates over every possible Position within this level."""
- for row in self.size.rows:
- for col in self.size.cols:
- yield Position(row, col)
-
- def find_thing(self, thing):
- """Returns the tile containing the given Thing, which must exist on this
- dungeon level.
- """
- return self[self._thing_positions[thing]]
-
-
- def travel(self, actor, target):
- """A thing is moving to a new target position. `target` may be either
- a Position or an Offset.
-
- This method makes no attempt to validate whether the thing has the
- ability to move this far, or indeed at all -- it's only concerned with
- the arrival of the thing on the new square. For example, traps will
- activate, and the move will be canceled if there's a wall or monster at
- the target position.
-
- Returns the new position's tile, or None if the movement is impossible.
- """
- # XXX return something more useful?
-
- old_position = self._thing_positions[actor]
-
- # If an offset is given, apply it to the thing's current position
- if isinstance(target, Offset):
- new_position = old_position.plus_offset(target)
- else:
- new_position = target
-
- # Check whether the target tile will accept the new thing
- for thing in self[new_position]:
- if not thing.can_be_moved_onto(actor):
- # XXX should this be an exception?
- return
-
- # Perform the move
- self[old_position].remove(actor)
- self[new_position].add(actor)
- self._thing_positions[actor] = new_position
-
- # Let the new tile react
- for thing in self[new_position]:
- thing.trigger_moved_onto(actor)
-
- return self[new_position]
-
- def _place_thing(self, thing, position):
- assert thing not in self._thing_positions
- self[position].add(thing)
- self._thing_positions[thing] = position
-
- def _remove_thing(self, thing):
- assert thing in self._thing_positions
- position = self._thing_positions.pop(thing)
- self[position].remove(thing)
-
+from raidne.game.fractor import RoomFractor
+from raidne.util import Offset, Position
class Dungeon(object):
- """The game world itself. This is the object the player interacts with
- directly. It also handles generating individual floors.
- """
+ """The game world itself."""
def __init__(self):
# TODO Need some better idea of how the dungeon should be structured.
# List of floors isn't really going to cut it. Floors should probably
# identify themselves and know their own connections, in which case:
# does the dungeon itself need to know much? Also, should floors
# remember their connections as weakrefs, or just identifiers that this
# object looks up?
+
+ fractor = RoomFractor()
self.floors = []
- self.floors.append(DungeonFloor())
- self.floors.append(DungeonFloor())
+ self.floors.append(fractor.generate())
+ self.floors.append(fractor.generate())
self.current_floor = self.floors[0]
@@ -180,22 +32,25 @@ def __init__(self):
# Create the player object and inject it into the first floor
# XXX grody
- self.player = Player()
- self.current_floor._place_thing(self.player, Position(1, 1))
-
- # Inject stairs into the first floor too
- stairs = StaircaseDown()
- self.current_floor[Position(1, 2)].architecture = stairs
+ self.player = things.Player()
+ self.current_floor.put(self.player, Position(3, 3))
+ def do_monster_turns(self):
+ return
### Player commands. Each of these methods represents an action the player
### has deliberately taken
# XXX: is passing the entire toplevel interface down here such a good idea?
def _cmd_move_delta(self, ui, offset):
- new_tile = self.current_floor.travel(self.player, offset)
- if new_tile and new_tile.items:
+ try:
+ new_tile = self.current_floor.move(self.player, offset)
+ except exceptions.CollisionError:
+ return
+
+ items = new_tile.items
+ if items:
ui.message(u"You see here: {0}.".format(
- u','.join(item.name() for item in new_tile.items)))
+ u','.join(item.name() for item in items)))
def cmd_move_up(self, ui):
self._cmd_move_delta(ui, Offset(drow=-1, dcol=0))
@@ -207,22 +62,25 @@ def cmd_move_right(self, ui):
self._cmd_move_delta(ui, Offset(drow=0, dcol=+1))
def cmd_descend(self, ui):
- # XXX is this right
- if not isinstance(self.current_floor.find_thing(self.player).architecture, StaircaseDown):
+ map = self.current_floor
+ # XXX is this right? perhaps objects should instead respond to
+ # attempted actions themselves.
+ if not isinstance(map.find(self.player).architecture, things.StaircaseDown):
ui.message("You can't go down here.")
return
- self.current_floor._remove_thing(self.player)
- self.current_floor = self.floors[1] # XXX uhhhhhh.
+
+ map.remove(self.player)
+ map = self.current_floor = self.floors[1] # XXX uhhhhhh.
# XXX need to put the player on the corresponding up staircase, or
# somewhere else if it's blocked or doesn't exist...
- self.current_floor._place_thing(self.player, Position(1, 1))
+ map.put(self.player, Position(1, 1))
def cmd_take(self, ui):
- tile = self.current_floor.find_thing(self.player)
+ tile = self.current_floor.find(self.player)
items = tile.items
self.player.inventory.extend(items)
for item in items:
- self.current_floor._remove_thing(item)
+ self.current_floor.remove(item)
ui.message("Got {0}.".format(item.name()))
View
70 raidne/game/fractor.py
@@ -0,0 +1,70 @@
+"""Procedural generation of dungeon maps. "Fractor" is the agent noun form of
+"fractal", where "fractal" is a verb for the purposes of this explanation.
+"""
+from raidne.game import things
+from raidne.game.map import Map
+from raidne.util import Position
+
+class Fractor(object):
+ """A procedural generator for a map. Creates a map, randomly decorates it,
+ etc. The actual logic of map creation is thus kept out of the map proper.
+
+ This is the base class. It doesn't do much of interest.
+ """
+ def __init__(self):
+ # XXX get the player attributes, options, state, whatever else here
+ pass
+
+ def generate(self):
+ """Returns a brand spankin' new map."""
+ # The general approach here (in theory) follows several steps:
+ # 1. Internally, draw rooms and hallways, so collision calculations can
+ # be done without scattering architecture objects everywhere.
+ # 2. Actually draw those onto the map. Include variations in floors
+ # and walls here.
+ # 3. Then fill in other elements, like traps and items and monsters.
+ raise NotImplementedError()
+
+class RoomFractor(Fractor):
+ """Generates maps containing a simple room."""
+
+ def _scrap_canvas(self, height, width):
+ # TODO flesh this out into a real class with abstract shape objects
+ # like Room(), Hallway(). probably. for now it's just a LoL
+ return [[None] * width for _ in xrange(height)]
+
+ def generate(self):
+ canvas = self._scrap_canvas(height=20, width=30)
+ self.draw_room(canvas, top=0, bottom=19, left=0, right=29)
+
+ # Place the stairs
+ canvas[10][10] = things.StaircaseDown()
+
+ map = Map.from_fractor_canvas(canvas)
+
+ # Place an item
+ map.put(things.Potion(), Position(2, 3))
+
+ return map
+
+ def draw_room(self, map, top, bottom, left, right):
+ """Draw a room with edges at the given offsets."""
+ assert top < bottom
+ assert left < right
+ assert 0 <= top < len(map)
+ assert 0 <= bottom < len(map)
+ assert 0 <= left < len(map[0])
+ assert 0 <= right < len(map[0])
+
+ # Draw the top and bottom walls
+ for col in xrange(left, right + 1):
+ map[top][col] = things.Wall()
+ map[bottom][col] = things.Wall()
+
+ # Draw the left and right walls, and the space inside
+ for row in xrange(top + 1, bottom):
+ map[row][left] = things.Wall()
+ map[row][right] = things.Wall()
+
+ for col in xrange(left + 1, right):
+ map[row][col] = things.Floor()
View
168 raidne/game/map.py
@@ -0,0 +1,168 @@
+"""Fairly dumb representation of dungeon geometry."""
+
+from collections import defaultdict
+
+import raidne.exceptions as exceptions
+from raidne.game import things
+from raidne.util import Position, Size
+
+class Map(object):
+ """Geometry of a dungeon floor. Functions both as structure (architectural
+ layout) and a two-dimensional container for things (monsters, items, etc).
+
+ Can't be instantiated directly! Use a fractor.
+ """
+ def __init__(self, _internal_call=False):
+ if not _internal_call:
+ raise TypeError("Can't instantiate Map directly; please use a fractor")
+
+ @classmethod
+ def from_fractor_canvas(cls, canvas):
+ self = cls(_internal_call=True)
+ self.size = Size(
+ rows=len(canvas), cols=len(canvas[0]))
+
+ # There are three layers of objects on any given tile:
+ # - exactly one architecture,
+ # - zero or more items, and
+ # - zero or one creatures.
+ # The latter two are taken care of with two sparse dictionaries and a
+ # lot of type-checking.
+ self._architecture = canvas
+ self._items = defaultdict(list)
+ self._critters = dict()
+
+ # TODO assert architecture is populated fully, somewhere
+
+ return self
+
+
+ def tile(self, position):
+ """Returns a little wrapper object representing this spot on the map.
+ """
+ assert position in self.size
+ return Tile(self, position)
+
+ def find(self, thing):
+ """Finds the given thing. Doesn't work on architecture."""
+ # TODO index me or whatever
+ for position, critter in self._critters.items():
+ if thing is critter:
+ return self.tile(position)
+ for position, thinglist in self._items.items():
+ if thing in thinglist:
+ return self.tile(position)
+ raise ValueError("No such thing on this map")
+
+ def put(self, thing, position):
+ """Put the given `thing` somewhere on the map."""
+ assert isinstance(position, Position)
+ assert position in self.size
+ # XXX assert thing not already on the map
+ # XXX possibly move the collision stuff here, instead of in move()?
+ if isinstance(thing, things.Creature):
+ assert position not in self._critters
+ self._critters[position] = thing
+ elif isinstance(thing, things.Item):
+ self._items[position].append(thing)
+ else:
+ raise ValueError("Don't know what that thing is")
+
+ def remove(self, thing):
+ position = self.find(thing).position
+ if isinstance(thing, things.Creature):
+ assert self._critters[position] is thing
+ del self._critters[position]
+ elif isinstance(thing, things.Item):
+ self._items[position].remove(thing)
+ else:
+ raise ValueError("Don't know what that thing is")
+
+ def move(self, actor, place):
+ """Moves the given thing somewhere else. `place` can be a position or
+ offset. If `actor` is already at `place`, nothing happens.
+
+ This is pretty low-level and just cares about the departure and
+ arrival. It doesn't deal with any of the following:
+ - whether the thing can move
+ - whether the thing is capable of moving to this new position
+
+ It DOES prevent moving to an impossible position, by raising a
+ CollisionError.
+
+ Returns the new position.
+ """
+ # XXX Return something useful?
+ # XXX Should this fire triggers on the target tile, or is that the
+ # caller's responsibility?
+ old_position = self.find(actor).position
+ new_position = place.relative_to(old_position)
+ assert new_position in self.size
+ if old_position == new_position:
+ return
+
+ # Check that the target tile accepts our movement
+ # TODO split this out
+ for thing in self.tile(new_position):
+ if not thing.can_be_moved_onto(actor):
+ raise exceptions.CollisionError()
+
+ # Perform the move
+ self.remove(actor)
+ self.put(actor, new_position)
+
+ # Let the new home target react
+ # XXX The more I think about this, the more I think it should be the
+ # caller's responsibility
+ #for thing in self._things[new_position]
+ # thing.trigger_moved_onto(actor) # this is wrong anyway; it'll trigger the actor on itself
+
+ return self.tile(new_position)
+
+class Tile(object):
+ """Transient class representing the contents of a single tile. Meant for
+ mucking about with a single point on the map more easily.
+ """
+ def __init__(self, map, position):
+ self.map = map
+ self.position = position
+
+ def __iter__(self):
+ """Iterates over things here, from top to bottom, including the
+ architecture at the bottom.
+ """
+ include_architecture = True
+
+ if self.position in self.map._critters:
+ yield self.map._critters[self.position]
+
+ for item in reversed(self.map._items[self.position]):
+ yield item
+
+ if include_architecture:
+ yield self.map._architecture[self.position.row][self.position.col]
+
+ @property
+ def topmost(self):
+ """The topmost thing here, including the architecture if this tile is
+ empty. That is, what thing you'd see looking down at this tile from
+ above.
+ """
+ return next(iter(self))
+
+ @property
+ def architecture(self):
+ """The architecture here."""
+ return self.map._architecture[self.position.row][self.position.col]
+
+ @property
+ def items(self):
+ """Returns the items here, in order from top to bottom."""
+ return list(reversed(self.map._items[self.position]))
+
+ @property
+ def creature(self):
+ """The creature here, or `None`."""
+ if self.position in self.map._critters:
+ return self.map._critters[self.position]
+ return None
View
39 raidne/ui/console/__init__.py
@@ -7,7 +7,7 @@
from raidne.game.dungeon import Dungeon
from raidne.ui.console.rendering import PALETTE_ENTRIES, rendering_for
-from raidne.util import Offset
+from raidne.util import Offset, Position
# TODO probably needs to be scrollable -- in which case the SolidFill overlay below can go away
class PlayingFieldWidget(urwid.FixedWidget):
@@ -25,12 +25,14 @@ def render(self, size, focus=False):
# Build a view of the architecture
viewport = []
attrs = []
- for row in range(self.dungeon.current_floor.size.rows):
+ map = self.dungeon.current_floor
+
+ for row in xrange(map.size.rows):
viewport_chars = []
attr_row = []
- for col in range(self.dungeon.current_floor.size.cols):
- tile = self.dungeon.current_floor[row, col]
- char, palette = rendering_for(tile.topmost_thing)
+ for col in xrange(map.size.cols):
+ topmost_thing = map.tile(Position(row, col)).topmost
+ char, palette = rendering_for(topmost_thing)
# XXX this is getting way inefficient man; surely a better approach
# TODO pass the rle to TextCanvas
@@ -45,10 +47,6 @@ def render(self, size, focus=False):
return urwid.CompositeCanvas(urwid.TextCanvas(viewport, attr=attrs))
def keypress(self, size, key):
- # TODO it seems like these should be handled by the interface object.
- # maybe that should become a widget itself and get keypresses from
- # here? surely something besides a dungeon FLOOR object should be
- # responsible for this.
if key == 'q':
raise ExitMainLoop
@@ -65,6 +63,7 @@ def keypress(self, size, key):
elif key == ',':
self.dungeon.cmd_take(self.interface_proxy)
elif key == 'i':
+ # XXX why does this go through the proxy?
self.interface_proxy.show_inventory()
else:
return key
@@ -73,9 +72,27 @@ def keypress(self, size, key):
# could use some more finely-tuned form of repainting
self._invalidate()
+ # TODO the current idea is that this will just run through everyone who
+ # needs to take their turn before the player -- thus returning
+ # immediately if the player didn't just do something that consumed a
+ # turn. it'll need to be more complex later for animating, long
+ # events, other delays, whatever.
+ self.dungeon.do_monster_turns()
+
+ self._invalidate()
+
def mouse_event(self, *args, **kwargs):
return True
+class PlayerStatusWidget(urwid.Pile):
+ def __init__(self):
+ widgets = []
+
+ widgets.append(('flow', urwid.Text("HP 10")))
+ widgets.append(urwid.SolidFill('x'))
+
+ urwid.Pile.__init__(self, widgets)
+
### Inventory
@@ -157,10 +174,12 @@ def init_display(self):
urwid.SimpleListWalker([])
)
- self.player_status_pane = urwid.SolidFill('x')
+ self.player_status_pane = PlayerStatusWidget()
top = urwid.Columns(
[play_area, ('fixed', 40, self.player_status_pane)],
)
+ # TODO this ought to be a top-level thing that worries about creating
+ # its own children, and *it* should listen for keypresses...
self.main_layer = urwid.Pile(
[top, ('fixed', 10, self.message_pane)],
)
View
20 raidne/util.py
@@ -19,20 +19,26 @@ def __contains__(self, position):
return (
position.row >= 0 and
position.col >= 0 and
- position.row <= self.rows and
- position.row <= self.cols
+ position.row < self.rows and
+ position.row < self.cols
)
class Position(namedtuple('Position', ['row', 'col'])):
"""Coordinate of a dungeon floor."""
__slots__ = ()
- def plus_offset(self, offset):
- """Returns a new Position shifted by the given Offset."""
- return type(self)(
- self.row + offset.drow,
- self.col + offset.dcol)
+ def relative_to(self, position):
+ """Compatibility with `Offset.relative_to`."""
+ return self
class Offset(namedtuple('Offset', ['drow', 'dcol'])):
"""Distance traveled from a Position."""
__slots__ = ()
+
+ def relative_to(self, position):
+ """Returns a new absolute `Position`, by applying this one to the
+ passed `position`.
+ """
+ return Position(
+ self.drow + position.row,
+ self.dcol + position.col)

0 comments on commit a1a6ea3

Please sign in to comment.