Scrolling (cd)
We’ll glance at the scrolling code, see what we can improve, and use it on our existing treasures.
Hm, there’s an issue already. The Scroller is rather obviously a view thing, but the Dungeon takes care of the handing out contents. We’ll look at that but let’s first review the Scroller situation as it stands:
class DungeonView(arcade.View):
def __init__(self, dungeon, testing=False):
if not testing:
super().__init__()
self.dungeon = dungeon
self.key_lock = None
self.shape_list = None
self.camera = None
self.camera_bounds = None
self.scroller = None
self.text_camera = None
if testing:
return
def setup(self):
self.setup_scroller()
self.setup_camera()
self.shape_list = arcade.shape_list.ShapeElementList()
self.create_rooms(self.shape_list)
def setup_camera(self):
self.camera = arcade.Camera2D()
zoom = 4
self.camera.zoom = zoom
w = 64
h = 56
width = w * cell_size
height = h * cell_size
width_in_cells = w // zoom
height_in_cells = h // zoom
width_margin = width_in_cells // 2 * cell_size
height_margin = height_in_cells // 2 * cell_size
self.camera_bounds = arcade.LRBT(
width_margin,
width - width_margin,
height_margin,
height - height_margin)
self.text_camera = arcade.Camera2D()
def setup_scroller(self):
self.scroller = Scroller(lines=4, base=(512,800))
for message in [
'You have discovered a gold coin!',
'There is a small cage here.',
'Your lamp has gone out!!',
'You have been eaten by a grue!',
]:
self.scroller.announce(message)
And the Scroller itself:
class Scroller:
def __init__(self, lines=4, base=(512,800)):
self.buffer = []
self.base_x, self.base_y = base
self.lines = lines
self.line_height = 36
self.y_bump = 0;
def announce(self, message: str) -> None:
self.buffer.append(message)
def message(self, number: int) -> str:
try:
return self.buffer[number]
except IndexError:
return ''
def on_draw(self):
for line in range(self.lines):
y_pos = self.y_position(line)
message = self.message(line)
text = arcade.Text(message, x=self.base_x, y=y_pos, font_size=24)
text.x = text.x - text.content_width // 2
text.draw()
def on_update(self):
self.y_bump += 0.5
if self.y_bump >= self.line_height:
self.y_bump = 0
self.buffer.pop(0)
if len(self.buffer) < self.lines:
self.announce('')
def y_position(self, line_number):
return self.base_y - line_number * self.line_height + self.y_bump
Let’s refactor to separate out things better.
Rename text_camera to scroller_camera. Commit. Rename setup_camera to setup_cameras. Commit.
Extract most of setup_cameras to setup_dungeon_camera:
def setup_cameras(self):
self.setup_dungeon_camera()
self.scroller_camera = arcade.Camera2D()
Commit. Rename self.camera to self.dungeon_camera. Commit. Rename camera_bounds to dungeon_camera_bounds. Commit.
Now the messy dungeon camera setup is off on its own, to be dealt with separately. We are here for the Scroller just now.
Now when we run over a content item, in Dungeon (not DungeonView), this happens:
class Dungeon:
def place_player_at(self, cell):
self.player_cell = cell
for item in list(self.contents_at(cell)):
item.interact_with_player(self)
class ContentItem:
def interact_with_player(self, player):
player.receive_item(self)
player.announce_received(self.name)
class Dungeon:
def announce_received(self, item_name):
print(f'You have found: {item_name}')
That last line wants to do an announce to the Scroller, which we do not have in Dungeon. What might we do?
- We could create the Scroller in Dungeon and access it from the DungeonView.
- We could create it in the View and pass it up to the Dungeon when the view is created.
- We could ask the Dungeon, from the View, to create a Scroller and maintain it there, returning the instance back to the View.
- Probably some other thing …
Let’s try creating it in the Dungeon and having an accessor for the DungeonView to use. (We might even make a scroller View.)
Hm, as I write that it occurs to me that in my iPad dungeon game, I retained all the announcements, and could replay the whole history of them upon request. What if Dungeon were to retain all the announcements, and we could ask it to deliver an announcement by index starting from zero?
Or … what if Dungeon maintained a buffer of announcements and we could request it and upon returning them, it would empty itself. That sounds possible.
Ah! How about this? We’ll buffer messages in Dungeon, and have a callback method on Dungeon, announce_messages or something, and pass it the view and Dungeon will cal announce for each of its buffered messages, emptying its buffer. We’ll call that on every update.
Let’s see if we can TDD that. It’s a Dungeon feature.
def test_announcements(self):
def count_them(msg):
nonlocal count
count += 1
layout = DungeonLayout(10, 10)
room_cell = layout.at(5, 5)
room = Room([room_cell])
layout.add_room(room)
dungeon = Dungeon(layout)
item_1 = ContentItem("treasure")
item_2 = ContentItem("more treasure")
dungeon.place_content_at(room_cell,item_1)
dungeon.place_content_at(room_cell,item_2)
assert len(dungeon.announcements) == 0
dungeon.place_player_at(room_cell)
count = 0
dungeon.announce_via(count_them)
assert count == 2
assert len(dungeon.announcements) == 0
This requires Dungeon to have a collection announcements, and to have a callback method announce_via that calls the callback. We want it to pass the messages but we are not checking for that here.
In Dungeon:
class Dungeon:
def announce_received(self, item_name):
msg = f'You have found: {item_name}'
self.announcements.append(item_name)
def announce_via(self, callback):
for announcement in self.announcements:
callback(announcement)
self.announcements = []
Plus an init of announcements to [] in __init__. I think this should work with a bit of a change to the DungeonView code, like this:
class DungeonView:
def on_update(self, delta_time: float):
def receive(item_name):
msg = f'You have received: {item_name}!'
self.scroller.announce(msg)
self.dungeon.announce_via(receive)
self.scroller.on_update()
Before we update the scroller, we request all the new messages from Dungeon, announcing them to the scroller. This works as advertised. Commit.
Review and Reflection
When a content item interacts with the player and gives them something, a message is filed in the announcements collection of the dungeon, consisting of the item name. The DungeonView maintains a scroller for displaying messages, and upon update, it asks the Dungeon, of which it is a view, to pass all the buffered messages to a function it provides, which announces them to the Scroller. It then continues with the rest of the Scroller update and ultimately its drawing.
- Step on item -> item name is announced to Dungeon -> name saved in
announcements. - DungeonView updates -> request announcements from Dungeon -> multiple calls to
scroller.announcewith formatted message. - On draw, scroller scrolls as usual.
I feel rather good about that, because the communication between Dungeon and View is via a callback, upon request from the view, rather than a push from either the Dungeon or the ContentItem or player.
Minor Improvements
I think that the methods in Scroller should be draw and update, not on_draw and on_update. Make it so. Commit.
Let’s move Scroller and ContentItem to the src side of things. They have been residing with the tests up until now. Done. Commit.
Looking Around
I create a startup message:
def setup_scroller(self):
self.scroller = Scroller(lines=4, base=(512,800))
for message in [
'Welcome to the Friendly Dungeon of Doom',
'Here you will encounter many new friends,',
'some of whom will not attack you.',
'Some of you may die, but we are willing to make that sacrifice.',
'Good luck! You will need it.'
]:
self.scroller.announce(message)
And a slight change to the Scroller, to init with blank lines so that the startup message begins at the bottom of the scroll:
class Scroller:
def __init__(self, lines=4, base=(512,800)):
self.buffer = []
for i in range(lines):
self.buffer.append('')
self.base_x, self.base_y = base
self.lines = lines
self.line_height = 36
self.y_bump = 0
I think this is a good stopping point.
Summary
I like the setup with Dungeon receiving announcements, and DungeonView asking it to send them along to it. I’m not sure we’ll stick with the simple form of a string containing an item name. We’ll have to see what happens when we encounter someone in the Dungeon we can talk to, or fight with. But that is for another day.
We still have magic numbers there in the init, the base, the line height, and the x y coordinates of the scroll and such, which need to be at least somewhat conditioned by window size. And, if we were ever to enable window resizing … things could get exciting. That, too, is for another day.
As to the base size, I think we’ll need the Dungeon to give us its dimensions in cells and we’ll need to convert those to pixels as needed. We have a tiny module params that contains that kind of info, and we should really do something different there. And that, too, will await another day.
For today, we’ve got a nice scroller and the code is better than it was this morning and it’s not all that bad. It’s still a fairly thin wire for communication of what’s going on but it, or a connection like it, will probably serve as a basis for future changes. We’ll see.
Maybe we’ll crash and burn, but I bet we won’t. See you next time!