Toward a Little Language?
Suppose we wanted to create many different kinds of maps. How might we make it easier?
If we were using this program to build a giant map-based game, or series of games, one big task would surely center around building maps of various general types, caves, castles, villages, towns, whatever. Because the program wants to be essentially endless, supporting a new adventure any time someone wanted one, we might want random map generators, such as we sort of have now. And we would surely want to make the job as easy as we reasonably could.
I don’t think that’s quite where we are going with this little program. I’m not sure where we are going but I don’t think it’ll get that large or support that many styles. But we can do a few and I think we’ll find some interesting little problems as we do.
All our maps are built, thus far, with various fiddling in main. Here’s today’s version:
def main():
space = CellSpace(64, 56)
# random.seed(234)
dungeon = Dungeon()
number_of_rooms = random.randint(10,10)
maker = RoomMaker(space)
origin = space.at(32, 28)
diamond = maker.diamond(25, origin)
diamond.return_cells()
round = maker.round(8, origin)
diamond.reclaim_cells_from(round)
dungeon.add_room(diamond)
dungeon.add_room(round)
for _ in range(number_of_rooms):
# size = random.randint(60, 100)
# origin = space.random_available_cell()
# room = maker.cave(size, origin)
# dungeon.add_room(room)
# origin = space.random_available_cell()
# room = maker.diamond(random.choice([25, 61]), origin)
# dungeon.add_room(room)
# origin = space.random_available_cell()
# rad = random.randint(4, 7)
# room = maker.round(rad, origin)
# dungeon.add_room(room)
origin = space.random_available_cell()
size = random.randint(100, 200)
room = maker.experimental(size, origin)
if len(room.cells) < 100:
print(len(room.cells))
dungeon.add_room(room)
# for room_1, room_2 in zip(dungeon.rooms, dungeon.rooms[1:]):
# dungeon.find_path_between_rooms(space, room_1, room_2)
while not dungeon.is_fully_connected:
origin = space.random_available_cell()
if origin is None:
break
size = random.randint(100, 200)
room = maker.experimental(size, origin)
if len(room.cells) >= 100:
dungeon.add_room(room)
else:
room.return_cells()
print(f'too small {len(room.cells)} cells')
print(f'{dungeon.is_fully_connected=}\n{len(dungeon.rooms)=}')
view = DungeonView(dungeon)
view.main_loop()
The commented-out bits are saved from various eyeball tests of our different room types. Just for fun, let me turn on all the code, to see what kind of bizarre map we get.
Here are the first three that it randomly produced:



When I look at these, I certainly see the similarity, but to me the first one says “lots of diamonds”, the second “lots of round”, and the third “mix of small diamond/round”. Sort of. Your eyes might pick out something different.
Is there a plan here somewhere?
Yes, in fact, there is a weakly-held sort of plan. Our current cell collectors, which lay out our rooms, have similar but not identical calling sequences. They are all created on a CellSpace from which they draw their cells. They all take a single provided cell as origin. Three of them take number_of_cells as parameter, and one takes radius. (I think that radius would work better for the diamond, which presently takes number_of_cells.)
I have these ideas loosely in mind:
-
Move toward a scheme where map layouts are more like data than procedure. Base them on keyword-value pairs, probably in dictionaries. (In principle, could be strings, JSON, etc.)
-
Change the calling sequences of the methods in RoomMaker, and the corresponding
buildmethod in the cell collectors, to require keyword parameters. This would make it easier to get things right, or at least harder to get them wrong. -
Change some or all of them to accept a dictionary as parameter, with the same keywords.
-
Allow a type keyword, initially ‘cave’, ‘diamond’. ‘round’, so that a map could be defined as a list of dictionaries.
-
[A miracle occurs about here.] Extend the “language” to allow for looping and perhaps even conditionals.
That’s the most cogent presentation I’ve created. What has gone before has been idle musing, no diagrams, sketches, or notes. The list above makes me think further …
-
We’ll probably want some kind of Map class that does this, and there will probably be more little helper classes like our CellCollection things. I don’t want to rush into this and expect the objects to change, as we discover what works well and what doesn’t.
-
We’ll want to keep things running. We may want to come up with some kind of approval tests to help with that: checking lots of different maps visually isn’t going to be useful.
-
Python’s support for collections is strong and urges one to use them natively. My experience suggests that system collections should be wrapped in classes we own. There will be some tension here, and probably some mistakes.
-
We should get started.
Keyword Arguments
I don’t know how much help PyCharm has for keyword arguments. Let’s look at a ChangeSignature dialog. Wonderful! If I insert a * parameter and move it up after self, PyCharm does the work:
class RoomMaker:
def diamond(self, *, number_of_cells: int, origin: Cell, name='diamond'):
# layer sizes: 1 4 8 12 16 20
# full sizes: 1 5 13 25 41 61
cells = DiamondCellCollector().build(number_of_cells, origin)
return Room(cells, origin, name)
def test_diamond_room(self):
space = CellSpace(64, 56)
maker = RoomMaker(space)
room_start = space.at(30,30)
room = maker.diamond(number_of_cells=13, origin=room_start)
assert len(room.cells) == 13
Green. Let’s commit: starting to use keyword arguments in cell collectors.
I’m going to go through all of these. So far I’ve done the RoomMaker’s four methods, four commits. I”ll move on to the build methods. Quickly done. Eight refactorings, eight commits. Pleasing.
Reflection
I noticed something as I did this. The RoomMaker is created with a CellSpace. It is not used. All the RoomMaker room construction methods are called with a Cell. Cells know their space, and all the operations that we’ve needed so far are done without a direct reference to the space. I’m tempted to remove it. My ancient reflexes say, no, leave it in case we need it. My more modern, less reflexive knowledge says we can add it later if we need it, and simpler is better. Remove it, with Change Signature.
But there’s more. The methods of RoomMaker make no reference to the instance variables: there are none. We could make them all class methods and then you could say RoomMaker.cave instead of RoomMaker().cave. However, that seems to me to be imprudent. As we build toward our quasi-intelligent map making, we are very likely to want to keep track of information as we go. Moving away from an instance to a class reduces our flexibility in a way that I don’t feel good about. So we’ll not touch that: it might be hot.
What’s Next?
I think the next steps should approach creating a Map with some kind of “style”. That would give us a block of code that we’d like to make easier, and it might serve as a platform for changes to a more declarative way of specifying the map, which is our overall goal here.
And quite likely the place to begin that work is in main, which, as you’ve seen above, is really just a series of make this / make that calls, used to create maps that we could look at. We can begin there to extract some code that should lead us somewhere interesting.
We’ll break for today, and I’ll think about some room layouts, maybe even sketch them. We’ll see where that leads. One discovery that I think we’ll make is that we need something better than our current approach to full connection, which is just to throw rooms in until we’re connected. We’ll aim at a fairly sparse map with a few rooms and a few corridors. Our current most random rooms create a lot of corridors, but they also make big room spaces. Maybe we can do better.
We’ll see you next time. Here’s a map that came out after the refactoring. I cut the room loop down to just two iterations. I like the corridor parts of this one.
