Things to Finish
Hello, loves!
We’re on the hook for extending illumination, and we need some way to remove the Path of Skulls.
When Dot has ‘a brilliant torch’, we want to illuminate whole rooms as she enters them. And after we build the Necrotic Path of Skulls, we should find a sensible way to remove it. I think we’ll need an actual idea for that bit.
If memory serves — Objection! Assumes facts not in evidence! — we already have the full-room illumination code somewhere. Let’s look.
class RoomView:
def illuminate(self):
dot = self.dungeon.player_cell
self.illuminate_around(dot, 4)
def illuminate_around(self, center, distance):
for cell, sprite in self.cell_sprites.items():
if cell.manhattan_distance(center) < distance:
self.dungeon_view.light_up(cell)
sprite.visible = True
def illuminate_room(self):
for cell, sprite in self.cell_sprites.items():
self.dungeon_view.light_up(cell)
sprite.visible = True
illuminate_room is only called by a test. Good that it’s tested, since it clearly couldn’t possibly fail.
We are faced with the question of who decides which method to use. We can certainly imagine that there might be multiple reasons to illuminate a room fully. Maybe Dot has a spell she can cast. Maybe a boss room stays dark until suddenly the lights go on.
As things stand, the Dungeon moves Dot:
def move_player(self, direction):
new_cell = self.player_cell.attempt_move(direction)
if new_cell != self.player_cell and self._interactions_allow_move(new_cell):
self.just_set_player_position(new_cell)
else:
self._redo_interactions_in_current_cell()
But the DungeonView requests the move and then illuminates:
def move_player(self, direction: Direction):
self.dungeon.move_player(direction)
self.illuminate()
def illuminate(self):
dot = self.dungeon.player_cell
if not dot: return # crock to allow a test to run
room = dot.room
view = self.room_views[room]
view.illuminate()
for content_view in self.content_views_by_room[room]:
content_view.illuminate(dot, range=4)
All this happens on every move. When we’re illuminating incrementally, that’s just right. Let’s not worry about illuminating an already illuminated room. Maybe we could add a parameter to Room.illuminate saying whether to do the whole room. I don’t like boolean parameters much, though.
The illuminate method is fetching dot’s cell. Let’s pass that instead:
def illuminate(self, cell):
self.illuminate_around(cell, 4)
def illuminate_around(self, center, distance):
for cell, sprite in self.cell_sprites.items():
if cell.manhattan_distance(center) < distance:
self.dungeon_view.light_up(cell)
sprite.visible = True
Looking at that tells me what we should really do. center is a cell, the center of illumination. Let’s call illuminate_around directly from DungeonView:
class DungeonView:
def illuminate(self):
dot = self.dungeon.player_cell
if not dot: return # crock to allow a test to run
room = dot.room
view = self.room_views[room]
view.illuminate_around(dot, 4)
for content_view in self.content_views_by_room[room]:
content_view.illuminate(dot)
Remind me to look at that content_view code to see how it works. I hope it’s checking cell illumination or something.
OK, now in DungeonView we can decide which method to call.
def illuminate(self):
dot = self.dungeon.player_cell
if not dot: return # crock to allow a test to run
room = dot.room
view = self.room_views[room]
if self.dungeon.dot_has('a brilliant torch'):
view.illuminate_room()
else:
view.illuminate_around(dot, 4)
for content_view in self.content_views_by_room[room]:
content_view.illuminate(dot)
Now I expect full room illumination to work. Oh, well, except for dot_has not being implemented. Duh.
class Dungeon:
def dot_has(self, desired_name):
return any(content.name == desired_name for content in self.player_inventory)
And it works:

Super, let’s commit: Rooms fully illuminate when Dot has ‘a brilliant torch’.
We were going to inspect ContentView’s illuminate. Sure enough, it is questionable:
class ContentView:
def illuminate(self, dot, range):
if dot.manhattan_distance(self.cell) < range:
self.sprite.visible = True
I believe that if we were to illuminate a room with Dot too far away from some content item, it would not appear. That is exactly what happens. Content still does not appear until Dot is within three steps of it. While that might be an interesting feature it is not what we want.
And this gives me an idea for a hack, which might be a righteous one. Clearly if we give this method a large range, it will illuminate all the stuff. So why not give the room illumination a large range also, and get rid of the full room illumination method entirely?
I think we’ll do that.
class DungeonView:
def illuminate(self):
dot = self.dungeon.player_cell
if not dot: return # crock to allow a test to run
room = dot.room
view = self.room_views[room]
if self.dungeon.dot_has('a brilliant torch'):
radius = 1000
else:
radius = 4
view.illuminate_around(dot, radius)
for content_view in self.content_views_by_room[room]:
content_view.illuminate(dot, radius)
Works. Now remove the full room illumination method and its test. Commit: simplify illumination to use large range instead of special method for full-room illumination.
Reflection
How should we feel about this sequence? Over a few days, we have written a test for full room illumination, implemented a method to do it, modified the room illumination to use it, and then decided just to illuminate a large radius and removed the test and method entirely.
Are you trying to make me feel badly about not thinking of the radius idea sooner? Sorry, no. My goal is to be smarter today than I was yesterday, and today that goal has been accomplished. Did I “waste” some time? Compared to what? Compared to whom? Some idealized person who doesn’t exist or doesn’t work here? Bite me entirely. Feel free to hire that person or read their article about how they thought of that idea right away.
As my brother Hill puts it, we are in the business of changing code. We change it to do things it can’t do, to make it do those things better, or faster, or more simply. We don’t expect to go straight from here to there: we find our way based on what really happens in the course of changing code.
The most reaction you’ll see from me after an egregious screw-up is a rueful chuckle. I know I’m flawed. And I also know that I’m the best programmer in this chair and I’ve made mistakes that make whichever one we’re looking at today seem laughable. When I sat down this morning, we didn’t have full room illumination. Now we have it, and the code base is easily 25 lines smaller than when I sat down.
Some other dude made it that large. I improved it. Makes me smile.
What Was That Other Thing?
Oh, right, removing the Necrotic Path of Skulls. What might we want to have happen?
One thing seems clear: we don’t want the path just lying there forever, the place would become a mess. The idea of the thing is that Dot just cannot work out where something she needs is, and the game draws her a path to find it. We have no actual use case for it yet. Presumably Dot is supposed to follow the path. In some spaces, though not always, she will actually have to tread on the markers, be they skulls, slime balls, or kittens. But we harm no kittens, so when Dot steps on one of the markers, it should surely disappear. We could give the item to Dot but she has no use for Skulls, and the Cat Distribution System has not been installed here yet. So we want a ContentItem that is like Decor (which we are now using) but which disappears when you step on it. We might call it VanishingContent or DestructibleContent.
But I think we want more. After a delay, the path should probably disappear. If we were really cool, we’d make it start disappearing from the origin toward the target, so that Dot can chase it if it starts to vanish.
What if we were to give each item a timeout, starting from some large number of seconds and incrementing by one each time we lay down an item? So the first one vanishes after 20 seconds, then the next at 21, and so on?
That sounds like fun. I think we draw the list from the target end, so that will complicate things, but must a bit.
OK, let’s do it. Where is that code?
def show_path_to(self, item_name, dungeon_view):
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
item = ContentFactory().decor(name="skel", resource=my_resources + 'Skeleton1.png', scale=0.5)
path = self.find_path_to(item_name)
for cell in path:
self.place_content_at(cell, item)
dungeon_view.make_view_and_sprite(cell, item)
Ah. As things stand, there is just one actual ContentItem, and we make a number of sprites based on that one. If we want this smart behavior, I think we’ll need to do better.
When it’s decor, it’s probably harmless to have the same ContentItem in two or more locations. But with a smarter item, we’ll need one for each cell.
Remind me to do something about loading the resources on every creation of an item: it’s wasteful and costly and also takes too much time.
What do we want? A ContentItem that receives updates, accumulates time, and disappears after a while.
The spikes update. How do they work?
class ContentFactory:
def spikes(self, *, name):
resource1 = '/Users/ron/Desktop/DungeonTiles/png/objects/trap/1.png'
resource2 = '/Users/ron/Desktop/DungeonTiles/png/objects/trap/2.png'
resources = [resource1, resource2]
scale = 0.75
cases = {
0: (True, 0),
1: (False, 1),
2: (False, 0),
3: (True, 0),
}
info = SimpleNamespace(cycling=True, cases=cases, time=0)
def cycle(self, pub_sub, delta_time):
if not self.info.cycling: return
self.info.time += delta_time
if self.info.time >= 1:
self.info.time = 0
self.state = (self.state+1)%len(self.resources)
pub_sub.publish('state_number', self.name, content=self, state=self.state)
cycle_sub = Subscription(event='on_update', caller_id='view', callback=cycle)
def control(self, *, pub_sub, state):
try:
self.info.cycling, self.state = self.info.cases[state]
pub_sub.publish('state_number', self.name, content=self, state=self.state)
except KeyError:
return
control_sub = Subscription(event='control', caller_id=name, callback=control)
return Content(name=name, resources=resources, scale=scale,
info=info, subs=[cycle_sub, control_sub],
)
I wonder if we can TDD these things. We sure can: here’s a test for spikes:
def test_stops_cycling(self):
pub_sub = PubSub()
factory = ContentFactory()
spikes = factory.spikes(name='spikes')
spikes.run(pub_sub)
pub_sub.publish('control', 'spikes', state=0)
assert spikes.info.cycling is True
pub_sub.publish('control', 'spikes', state=1)
assert spikes.info.cycling is False
assert spikes.state == 1
pub_sub.publish('control', 'spikes', state=2)
assert spikes.info.cycling is False
assert spikes.state == 0
pub_sub.publish('control', 'spikes', state=3)
assert spikes.info.cycling is True
So let’s be for TDDing a new item. I think we’ll call it Temporary.
class TestTemporaryContent:
def test_hookup(self):
assert False
Fails. We’re on the trail now!
def test_creation(self):
pub_sub = PubSub()
factory = ContentFactory()
temp = factory.temp(name='temp', time=21, resource='', scale=0.5)
More than enough to get us started.
class ContentFactory:
def temp(self, *, name, time, resource, scale):
info = SimpleNamespace(elapsed=0, time=time)
subs = []
return Content(name=name, resources=[resource], scale=scale,
info=info, subs=subs)
Just a beginning, of course. We will need an update method and to subscribe it to update:
def test_creation(self):
pub_sub = PubSub()
factory = ContentFactory()
temp = factory.temp(name='temp', time=21, resource='', scale=0.5)
assert temp.info.elapsed == 0
pub_sub.publish('on_update', 'test', delta_time=1)
assert temp.info.elapsed == 1
Improve the factory:
def temp(self, *, name, time, resource, scale):
info = SimpleNamespace(elapsed=0, time=time)
def update(self, *, pub_sub, delta_time):
self.info.elapsed += delta_time
update_sub = Subscription(event='on_update', caller_id='view', callback=update)
subs = [update_sub]
return Content(name=name, resources=[resource], scale=scale,
info=info, subs=subs)
Test does not pass. I finally realize that the temp needs to be run before it’ll work. That’s how they initialize. Change the test to do that:
def test_creation(self):
pub_sub = PubSub()
factory = ContentFactory()
temp = factory.temp(name='temp', time=21, resource='', scale=0.5)
temp.run(pub_sub)
assert temp.info.elapsed == 0
pub_sub.publish('on_update', 'view', delta_time=1)
assert temp.info.elapsed == 1
Test is green. Commit: working on temp content
I think we have no way for an object to remove itself. How do items that give themselves to Dot work?
def receivable(self, *, name, resource, scale):
def interaction(self, interactor):
interactor.receive_content(self)
interactor.publish('announce', 'xx', message=f'You have received {self.name}!')
return True
return Content(name=name,
resources=[resource],
scale=scale,
interaction=interaction)
We’ll need that if we plan to remove these guys when stepped on, but that won’t do for timing out.
Ah, yes, the DungeonView is listening for a message about removing content, but all that does is remove the sprite:
class DungeonView:
def subscribe_to_remove_content(self, dungeon):
def callback(*, pub_sub, content):
self.view_do(
content,
lambda view: view.remove_from_sprite_lists())
dungeon.subscribe('remove_content', '', callback)
Does Dungeon subscribe to anything? Not yet. Here is how receiving content works:
class Dungeon:
def receive_content_from_cell(self, content, containing_cell):
contents = self.contents_at(containing_cell)
if content in contents:
self.publish('remove_content', '', content=content)
contents.remove(content)
self.player_inventory.append(content)
This seems wrong. This code removes everything from the cell, not just the provided content item.
I’ve made a note to look into that. We’re not on that mission right now.
Anyway when this happens, the view receives the remove_content message and removes the sprite. Our situation is that we are a content item and we want to be removed from our cell. We want our sprite to be removed as well. If we were to publish remove_content, I think the skull would disappear from view but still be present.
Let’s do that.
def test_creation(self):
called = False
def gone(*, pub_sub, content):
nonlocal called
called = True
pub_sub = PubSub()
pub_sub.subscribe('remove_content', '', gone)
factory = ContentFactory()
temp = factory.temp(name='temp', time=21, resource='', scale=0.5)
temp.run(pub_sub)
assert temp.info.elapsed == 0
pub_sub.publish('on_update', 'view', delta_time=1)
assert temp.info.elapsed == 1
assert not called
pub_sub.publish('on_update', 'view', delta_time=21)
assert called
I hope this is failing on the last assert. It is. Improve the temp.
def temp(self, *, name, time, resource, scale):
info = SimpleNamespace(elapsed=0, time=time)
def update(self, *, pub_sub, delta_time):
self.info.elapsed += delta_time
if self.info.elapsed >= self.info.time:
pub_sub.publish('remove_content', 'temp', content=self)
update_sub = Subscription(event='on_update', caller_id='view', callback=update)
subs = [update_sub]
return Content(name=name, resources=[resource], scale=scale,
info=info, subs=subs)
The test passes. Commit. Let’s use the temp in our path code.
def show_path_to(self, item_name, dungeon_view):
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
item = ContentFactory().temp(name="skel", time=10, resource=my_resources + 'Skeleton1.png', scale=0.5)
path = self.find_path_to(item_name)
for cell in path:
self.place_content_at(cell, item)
dungeon_view.make_view_and_sprite(cell, item)
Doesn’t work. A bit of probing tells me that the path content never receives the run method and thus they are not initialized and thus nothing happens. I add that here:
class Dungeon:
def show_path_to(self, item_name, dungeon_view):
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
path = self.find_path_to(item_name)
for cell in path:
item = ContentFactory().temp(name="skel", time=10, resource=my_resources + 'Skeleton1.png', scale=0.5)
self.place_content_at(cell, item)
item.run(self)
dungeon_view.make_view_and_sprite(cell, item)
Now let’s give them proper times.
def show_path_to(self, item_name, dungeon_view):
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
path = self.find_path_to(item_name)
time_out = 5 # seconds
for cell in path:
item = ContentFactory().temp(name="skel", time=time_out, resource=my_resources + 'Skeleton1.png', scale=0.5)
time_out += 1
self.place_content_at(cell, item)
item.run(self)
dungeon_view.make_view_and_sprite(cell, item)
Each skull lasts one second longer than the prior one. (I was mistaken about the list order, it is from dot to target, so we want to increase the times.)
This works as intended:
I’ve been at this too long without caffeine. There is still work to be done, the skulls are really still there, but we have the visible behavior we set out to create. So much of what we need is in place. We’ll commit again.
Summary
We completed the new illumination story with a net reduction of code and complexity compared to our original scheme.
We are still in progress on the path-showing story, with visible behavior looking as intended, with a new kind of content object that is doing the right thing so far. We have yet to remove the path items from the dungeon, although they do disappear from view. And we seem to have identified problem where if one item in a cell gives itself to Dot, all the cell content will be removed. And we want the path items to remove themselves when stepped upon as well as upon timeout.
There are many other interesting things to do, as we shape this thing into more and more the shape of an actual game.
Let’s review what we area really doing here, as best as I can guess it:
We are building a program that looks like a dungeon game being built incrementally. Our focus is on incrementally, with design being improved as we go, and with the repetitive details of game creation eliminated as much as we can. We create a capability of supporting some particular game feature, we create a visible tested instance of it, but we do not then go ahead and create masses of treasure, gangs of monsters, committees of NPCs, and so on.
We’re here to see what kinds of issues arise during the creation of such a program, and to see how we deal with them. We try to avoid as much of the tedium of game creation as we can, because we’re here to see how to craft and evolve code.
We’re here to do the things that I consider to be fun, and that I consider to be useful or interesting to the odd kind of person who cares about crafting code the old fashioned way: using one’s own brain and fingers.
I hope you enjoy it, and if you do not I hope you have the good sense to stop reading. See you next time!