Friday, July 6, 2012

EPI #13: Dialog

I had so much fun with listener action verbs (henceforth, LAVs) the other day that I decided to implement another one:  the "SAY" verb.  This does pretty much what you'd expect-- it draws text to the screen.  The concept shouldn't be foreign to anyone who's ever played an RPG:  You go up to an NPC, press a button, and read through someone's entire life story.

Even though it's a simple idea, getting arbitrary text to appear on an arbitrarily-sized screen turns out to be a remarkably difficult task.  The part of the implementation that concerns the event system is straightforward enough-- mostly it amounts to adding a SAY case to EPIEventHandler.eval_listener() and EPIEventListener.parse().  This isn't hard.  The hard part is that little niggling detail of actually getting text to appear on the screen.

It's challenging mostly because I have to pay attention to all of the little details about fonts and text that I usually take for granted.  Things like:
  • How many characters fit on a line?
  • How many lines fit in a given area?
  • Where is an appropriate place a line break?
  • What happens if the whole text to display won't fit in the display area?
In practice, answering these questions boils down to learning a few key details about the context I'm rendering fonts in.  For a given font face, point size, slant, and weight, I need to know:
  • The average dimensions of a glyph (width, ascent - descent)
  • The dimensions of my display area.
I say "average" because, unless I'm working with a monospaced font, the width and height will change slightly from glyph to glyph.

I started my solution with (surprise!) a class definition. class TextDrawer:
  • TextDrawer
    • context - the gtk.gdk.CairoContext I want to draw with.
    • origin - an OrderedPair representing the upper-left corner of the display area.
    • extents - an OrderedPair representing the width and height of the display area.
    • text - the text to draw.
    • padding - an integer number of pixels to offset the text from each border of the display area (the same idea as CSS padding).
    • is_drawing_text - a boolean indicating whether or not the TextDrawer is currently drawing text.
    • width_per_char - integer
    • height_per_line - integer
    • chars_per_line - integer
    • max_lines - integer
    • set_font(self, face, size, slant, weight)
    • draw_text(self)
    • advance_text(self)
It turns out that getting width_per_char, height_per_line, chars_per_line, and max_lines is really easy because gtk.gdk.CairoContexts know a text_extents() method, which tells you all the information you need to derive these values.  The TextDrawer performs the derivation in set_font(), and then uses these values to determine and draw one "page" of text in draw_text().  advance_text() discards the current page of text, allowing draw_text() to draw the next page.  is_drawing_text is True for as long as the TextDrawer has text to draw, and is changed to False by advance_text() when it's called with no more text to draw.

I use the term "page" here because it's a convenient abstraction.  In my implementation, all I'm doing is chopping up text into carefully chosen substrings and rendering those to the screen.  If you're really curious about how the algorithm for doing that works, I'll tell you in the comments if you ask.

So how does TextDrawer integrate with the rest of the EPI graphics infrastructure?  It's pretty simple, actually.  A MapWidget has a TextDrawer, and as part of its "expose_event" handler, it tells its TextDrawer to draw_text(), which then does or does not depending on whether or not it has text to draw.  The final bit of integration happens in MapWidget.key_press_handler().  This function's behavior depends on TextDrawer.is_drawing_text.  If is_drawing_text is True, all keypresses call TextDrawer.advance_text().  Otherwise, they behave normally.

To test this, I added the following listener to mapentity id=1:

<listener type='use' state='0'>SAY "It's empty!"</listener>

So then if I maneuver Kenfold next to my treasure chest, face him the appropriate way, and press 'e' (the default use/talk/select key)...
Being trolled by game developers is never fun.
From here, pressing any key closes the dialog and returns control to the player.  I also had an example with Ariel and 500 words of lorem ipsum to test multi-page text rendering, but my mspaint art for Ariel will actually give you nightmares, and lorem ipsum isn't all that exciting anyways.  You'll just have to take my word that it that functions correctly.

No comments:

Post a Comment