[RM-3] Speech Balloons That “Type” Their Message

Blog Info: I’m mostly done reading a great 65816 (SNES) assembly guide; this guide is so sensibly written that I think I’m actually ready to do a fun text hack for my favorite game of all time. So this week’s hack will help me prepare by introducing the algorithm in a high-level language. Plus, I think the RPG Maker community will like this one.

Back-story: Ok, our last attempt at speech balloons kind of flopped. They stretched text, they jittered, and they just popped into existence. Their worst sin, in my opinion, is that they didn’t “open” like the default RPG Maker ones, nor could they “type” their text out letter by letter. Let’s fix everything in one fell swoop.

Goal: Spruce up our speech balloon code, particularly in regards to opening/closing them when the player walks within range, and typing their message text out letter-by-letter.

You should read through tutorial RM-2 before starting this task. Instead of using a clean (new) project, I’m going to start where we left off after that tutorial. Don’t worry, I flushed my own project and copied it letter-for-letter from RM-2. One thing to note:

  • Your “Talkbox_Window” module needs to be in a location that the interpreter can find. For example, add it below the “Windows” tab, or “Materials”. If you just add it anywhere (say, at  the end of the file), you might get an error when you try to run the program.

I added this factoid to the original article, too. People, please tell me if you can’t get your code to work following one of my guides; I’ll be happy to fix it.

Step 1: Variable Width and Jitter Reduction
Add the following line to “Main” right after Graphics.freeze:

$Debug = Window_Base.new(544, 0, 544, 416)
$Debug.windowskin = nil

This just gives us an “offscreen” area to calculate stuff on. If you’re combining tutorial RM-1 with RM-2, you can ignore this, and just use the regular $Debug window for offscreen stuff.

Replace the Talkbox_Window class with the following one:

#A slightly improved RM-2 talkbox
class Talkbox_Window < Window_Base
  #RPG Maker defaults
  MAX_WINDOW_WIDTH = 544
  MAX_WINDOW_HEIGHT = 416
  WINDOW_PADDING = 32

  #npc is a GameEvent
  def initialize(npc, txt)
    #Figure out a proper size, and initialize it offscreen
    my_size = $Debug.contents.text_size(txt)
    super(0, 0,
       my_size.width + WINDOW_PADDING,
       my_size.height + WINDOW_PADDING)

    #Put off setting this variable until later
    @offset_y = -1

    #On top and invisible
    self.z = 90
    self.visible = false

    #Draw the text once
    self.contents.draw_text(0, 0, draw_width, draw_height, txt)
  end

  #Call this to update the talkbox’s position over an NPC
  def update_pos(npc)
    #Store the npc’s height if we haven’t done so yet
    @offset_y = npc.sprite_height if @offset_y==-1

    #Update this talkbox’s position
    self.x = npc.screen_x - self.width/2
    self.y = npc.screen_y - self.height - @offset_y
  end

  #Get the width of the client area
  def draw_width
    return self.width - WINDOW_PADDING
  end

  #Get the height of the client area
  def draw_height
    return self.height - WINDOW_PADDING
  end
end

The MAX_WINDOW_WIDTH and MAX_WINDOW_HEIGHT variables just happen to be the window sizes RPG Maker ships with. I couldn’t for the life of me find these anywhere in the documentation, so I just stored my own values here. The DEFAULT_PADDING represents how much space is used as “border space” by any RPG Maker window —if you don’t add this value to each window you create, you may  find that everything you draw  is “cut off”.

We cache the return value of sprite_height because we’re worried it will affect performance. We’ll describe why later.

The remainder of the code is pretty simple to understand, even though not all relevant functions have been implemented yet. We’re basically resizing the talkbox to hold all of its text, and centering it above the NPC in question.

Now we need to add all those missing functions. Open Game_Event and add the following two member definitions:

  def sprite_width
    #Easy if this is a tile-based event
    return 32 if @tile_id > 0

    #Harder if this is a sprite
    unless $scene.is_a?(Scene_Map)
      return 0   #A generic Scene isn’t good enough
    end
    return $scene.get_event_width(self)
  end

  def sprite_height
    #Easy if this is a tile-based event
    return 32 if @tile_id > 0

    #Harder if this is a sprite
    unless $scene.is_a?(Scene_Map)
      return 0   #A generic Scene isn’t good enough
    end
    return $scene.get_event_height(self)
  end

There’s probably a better way to get this information, but this will have to do until I get a chance to read all the RMVX documentation. Basically, we just return the size of a tile or the size of a sprite depending on what this event is represented by. Of course, our caching code will position talkboxes improperly if you keep changing your event’s graphics from a Tile to a Chara representation. You can remove the caching if this bothers you, but I find it to be an unlikely edge case.

As you might have guessed, we now have to edit Scene_Map. Add the following functions anywhere:

  def get_event_width(event)
    return @spriteset.get_event_sprite(event).width
  end
  def get_event_height(event)
    return @spriteset.get_event_sprite(event).height
  end

Often, you’ll find that the Scene_* classes contain the down-and-dirty details of the game engine, while the Game_* classes contain a higher-level “logical” set of data points. Why, I’d bet we could fix the screen jitter we discovered earlier by simply updating talkboxes here instead of in Game_Map. This is completely correct. Find the following line in Game_Map’s “update” method:

    update_talkboxes

…and remove it. Then, add the following line to Scene_Map’s “update” method, right after “@message_window.update”.

    $game_map.update_talkboxes   #Update NPC talkboxes

That’ll fix the 1-pixel jitter we noticed earlier. Why? My guess is that “@spriteset.update” (which occurs after “$game_map.update”) does a minor pixel correction on every sprite that’s scrolling. For Game_Map’s objects, this tiny offset isn’t important.

By the way, a lot of game objects have both a Game_* and a Sprite_* representation. We might consider doing the same thing for our windows; then we wouldn’t need this minor hack. However, I don’t think it’s important.

Add the following function to Spriteset_Map. The explicit loop this function contains, by the way, is the reason we cache @offset_y.

  def get_event_sprite(event)
    for character in @character_sprites
      return character if character.for_event?(event)
    end
  end

Finally, add the for_event? function to Sprite_Character:

  def for_event?(event)
    return event == @character
  end

All this is rather hackish, but it’s self-contained, so I don’t consider it such a sin. More importantly, it makes our talkboxes work! Create a few NPCs and give them long-ish textboxes. Have them walk around if you want. Start a new game and enjoy the much, much nicer talkboxes (note the walking cow).

Our New Boxes Auto-Size and Don't Jitter When You Walk.

Our New Boxes Auto-Size and Don't Jitter When You Walk.

Don’t worry about the fact that boxes appear on the map for a split second before moving above their respective NPCs; we’ll eventually start them all “closed”, which will fix this problem.

Step 2: Opening Boxes When In Range

We’ve come a long way, but  our talkboxes still need some major work. For one thing, it doesn’t make sense to show them all on-screen at once. Besides introducing an unnecessary bottleneck, this is also rather boring to look at from the player’s perspective. So, let’s start all talkboxes “closed” and then “open” them when an NPC steps into the farthest visible tiles. (We’ll close them if he steps out of bounds).

This will require oodles of code to be written. Phase one will be to make the windows open and type their text; the second phase will invoke this code when an NPC steps into bounds. We will borrow most of our new code from Window_Message. Here’s our new class:

#A version of a Message which appears over a single
# NPC’s head. Supports most text tags, and auto-sizing.
class Talkbox_Window < Window_Base
  #RPG Maker defaults
  MAX_WINDOW_WIDTH = 544
  MAX_WINDOW_HEIGHT = 416
  WINDOW_PADDING = 32

  #Create a new Talkbox_Window.
  #  @param npc – The GameEvent above which we show our talkbox
  #  @param txt – A String (or array of strings) which
  #               represents our message. (Arrays aren’t yet
  #               supported).
  def initialize(npc, txt)
    #Figure out a proper size, and initialize it offscreen
    #  Height is +3 to allow for g/p/y/etc.
    my_size = $Debug.contents.text_size(txt)
    super(0, 0,
       my_size.width + WINDOW_PADDING,
       my_size.height + 3 + WINDOW_PADDING)

    #Put off setting this variable until later
    @offset_y = -1

    #On top and invisible
    self.z = 90
    self.visible = false

    #Fully shrunken, and neither opening nor closing.
    self.openness = 0
    @opening = false
    @closing = false

    #Pause after opening? Note that we can’t use
    # self.pause; that'll set a bouncing graphic
    @passive_pause = false

    #Reset variables used to track the state of the
    # drawing routine
    @text = nil       #Our text buffer
    @contents_x = 0   #Next character’s X
    @contents_y = 0   #Next character’s Y
    @line_count = 0   #Lines drawn so far
    @wait_count = 0   #How long to pause (for \., etc.)
    @line_show_fast = false

    #Save our text-to-draw
    @orig_texts = Array(txt)
   end

  #
  # New functionality for text processing
  # 

  #Frame Update – All opening/closing/typing happens here
  def update
    #Handle opening/closing
    super

    #All other actions must wait for a fully-opened window
    unless @opening or @closing
      if @wait_count > 0
        #We’re pausing _within_ the text stream
        @wait_count -= 1
      elsif @passive_pause
        #The window is fully open; leave it open
        # (optionally, we can add code to scan for Input)
      elsif @text != nil
        #There's text we haven’t shown yet
        update_message
      elsif continue?
        #Open the window and show its text
        start_message
        open
      end
    end
  end

  #Start a new line of text
  def new_line
    @contents_x = 0
    @contents_y += WLH
    @line_count += 1
    @line_show_fast = false
  end

  #Should we be displaying the next message?
  # For now, the answer is “always”, but you can add your
  # own control code if you like
  def continue?
    return true
  end

  #Align all our text for processing into a single string with
  # embedded control characters
  def start_message
    @text = ""
    for i in 0...@orig_texts.size
      @text += @orig_texts[i].clone + "\x00"
    end
    convert_special_characters
    new_page
  end

  #Replace all special letters with single-character
  # equivalents. (Copied verbatim from Message)
  def convert_special_characters
    @text.gsub!(/\\V\[([0-9]+)\]/i) { $game_variables[$1.to_i] }
    @text.gsub!(/\\V\[([0-9]+)\]/i) { $game_variables[$1.to_i] }
    @text.gsub!(/\\N\[([0-9]+)\]/i) { $game_actors[$1.to_i].name }
    @text.gsub!(/\\C\[([0-9]+)\]/i) { "\x01[#{$1}]" }
    @text.gsub!(/\\G/)              { "\x02" }
    @text.gsub!(/\\\./)             { "\x03" }
    @text.gsub!(/\\\|/)             { "\x04" }
    @text.gsub!(/\\!/)              { "\x05" }
    @text.gsub!(/\\>/)              { "\x06" }
    @text.gsub!(/\\</)              { "\x07" }
    @text.gsub!(/\\\^/)             { "\x08" }
    @text.gsub!(/\\\\/)             { "\\" }
  end

  #Start a new “page” of input on the current message
  # box’s surface. Right now, we only have one page of
  # input, but there’s no reason you can’t add more.
  def new_page
    #Clear the background bitmap
    self.contents.clear

    #Reset our drawing state variables
    @contents_x = 0
    @contents_y = 0
    @line_count = 0
    @line_show_fast = false
    @passive_pause = false

    #Reset our text color to the default
    contents.font.color = text_color(0)
  end  

  #Called when a message has been printed in its entirety
  # (used below)
  def finish_message
    @passive_pause = true
    @wait_count = 10
    @text = nil
  end

  #This is the main text processing loop of the talkbox window.
  # It works by slicing one character at a time off of @text,
  # and either reacting to it as a control character,
  # or displaying it.
  def update_message
    loop do
      #Get next text character
      c = @text.slice!(/./m)
      case c
        when nil
          #There is no text waiting to be drawn
          finish_message
          break
        when "\x00"
          #Our custom "newline" character
          new_line
          #Multiple pages; left in for your reference
          #if @line_count >= MAX_LINE
          #  unless @text.empty?
          #    self.pause = true
          #    break
          #  end
          #end
      when "\x01"
        #\C[n]  (text character color change)
        @text.sub!(/\[([0-9]+)\]/, "")
        contents.font.color = text_color($1.to_i)
        next
      when "\x02"
        #\G  (gold display) -ignore
        break
      when "\x03"
        #\.  (wait 1/4 second)
        @wait_count = 15
        break
      when "\x04"
        #\|  (wait 1 second)
        @wait_count = 60
        break
      when "\x05"
        #\!  (Wait for input)
        break
      when "\x06"
        #\>  (Fast display ON)
        @line_show_fast = true
      when "\x07"
        #\<  (Fast display OFF)
        @line_show_fast = false
      when "\x08"
        #\^  (No wait for input)
        break
      else
        #Normal text character
        contents.draw_text(@contents_x, @contents_y, 40, WLH, c)
        c_width = contents.text_size(c).width
        @contents_x += c_width
      end
      break unless @line_show_fast
    end
  end

  #
  # The rest of our functionality is unchanged
  #

  #Call this to update the talkbox’s position over an NPC
  def update_pos(npc)
    #Store the npc’s height if we haven’t done so yet
    @offset_y = npc.sprite_height if @offset_y==-1

    #Update this talkbox’s position
    self.x = npc.screen_x - self.width/2
    self.y = npc.screen_y - self.height - @offset_y
  end

  #Get the width/height of the client area
  def draw_width
    return self.width - WINDOW_PADDING
  end
  def draw_height
    return self.height - WINDOW_PADDING
  end
end

What a whopper! Fortunately, this is fairly easy to understand: update() determines whether or not we should be calling update_message(), which reads letters one-by-one. All other methods are helpers, and their functions are relatively simple. I’ve got to hand it to the RPG Maker VX team: their code might be a bit cluttered, but it’s very easy to follow and understand. I’m consistently amazed at how sensible it is to hack the RPGMVX engine.

Now, add the following code in Game_Event’s update() method, right below check_event_trigger_auto:

#Update NPC talkbox
@npc_talkbox.update if @npc_talkbox != nil

This is much easier to understand; we just needed to hook up our frame update method when the Game Event calls its update method. Finally, add a new event to your map, with the following “Script” command:

for event in $game_map.events.values
  tkb = event.npc_talkbox
  next unless tkb
  tkb.openness = 0
  tkb.start_message()
  tkb.open()
end

Since windows stay open forever, we need a way to “re-type” the window, to make sure our system is working properly. Since we didn’t bother writing such a method (we will later), we just hooked it up manually for now.

Run your program; talk to your new NPC any time you want to reset every window. Ah…. Isn’t that nice? As an added benefit, our windows no longer show up on the title screen, since their “openness” only gets updated in the main game loop.

These TalkBoxes Show Their Messages Just Like a Normal RPGMVX Show_Message Box.

These TalkBoxes Show Their Messages Just Like a Normal RPGMVX Show_Message Box.

Before we make these talkboxes appear on demand, we have to decide when exactly we want to show them. I chose to wait for a talkbox to be fully onscreen vertically, and halfway onscreen horizontally before starting to open it. I made this decision partly because boxes are centered over NPCs, so a talkbox will now appear slightly before or after its respective NPC becomes visible. Add the following function to your Window_Talkbox class; it checks whether or not this box should be opened:

  #Is this box within the vertical boundaries, and at
  # least halfway within the horizontal boundaries?
  def in_range?
    #Store some marker variables
    mid_x = self.x+self.width/2
    min_y = self.y
    max_y = self.y+self.height

    #Check bounds
    return true if min_y>=0 and mid_x>=0 and
                   max_y<=MAX_WINDOW_HEIGHT and
                   mid_x<=MAX_WINDOW_WIDTH
    return false
  end

As you should have noticed, this function relies on the update_pos() function being called first. And, if you think about it, update_pos doesn’t work unless called in the Scene_* routine; calling it in the context of Game_* is a mistake. So, instead of juggling a whole slew of function calls, we’ll just append our code to update_pos(). Your update_pos function should now look like this:

  #Call this to update the talkbox’s position over an NPC
  def update_pos(npc)
    #Store the npc’s height if we haven’t done so yet
    @offset_y = npc.sprite_height if @offset_y==-1

    #Update this talkbox’s position
    self.x = npc.screen_x - self.width/2
    self.y = npc.screen_y - self.height - @offset_y

    #Now, show/hide we show this box?
    if @onscreen != in_range?
      unless @onscreen
        retype_text()
      else
        close_window()
      end
    end
  end

This relies on a new variable, @onscreen. Add it to your init function (anywhere)

@onscreen = false  #In-range?

Finally, you’ll need the functions retype_text() and close_window(). Add them anywhere:

def retype_text
  @onscreen = true
  openness = 0
  start_message()
  open()
end

def close_window
  @onscreen = false
  close
end

This should do the trick. The only thing left to do is to find the following lines in update’s “elsif continue?” branch and comment them out:

  # start_message
  # open

…otherwise, the window will always open itself, regardless of its position onscreen. Run your code:

Our Boxes Now Open When In Range, Completing Our Original Goal.

Our Boxes Now Open When In Range, Completing Our Original Goal.

The nice thing is, since we check this every tick (instead of just when the NPC moves) it should work properly for teleporting to a new map and teleporting NPCs around the map.

Note: The documentation states that if a Window’s “openness” value is less than 255, its “contents” Bitmap won’t be displayed. I am kind of assuming that, if its “openness” is 0, the windowskin also won’t be displayed, and processing will be minimal. I feel this is a safe assumption, but I want to make it clear that, if you experience a performance hit, you might want to look here first.

Possible Improvements

This tutorial was rather longish, so I’ll only give you two assignments this week.

  • It’s well known that inheritance simplifies code reuse. Yet, rather than reusing the code in Window_Message, we just copied it and deleted what we didn’t want. Wouldn’t it be better to abstract it into a common superclass, and have both Window_Message and Window_Talkbox subclass this new class? For our tutorial: no. Extracting this code could have broken both normal messages and our talkboxes. Our lightweight class only risks breaking itself, which makes it ideal for development. Now that we’re done proving the concept, however, it would be a good idea to create a common superclass. That’s your first assignment.
  • We copied the convert_special_characters() function directly from Window_Message, but closer inspection makes it clear that only a small number of backslashed “special letters” are used. Might we extend the list of possible escape sequences? Since we extract the text for our message balloon directly from a ShowMessage event, this provides a very elegant way of specifying additional properties of talkboxes. For example, we might use \{ACCEPT} to mean “this talkbox responds to the Accept key”, and \{EVENT:2} to mean “call event 2 when the user presses Accept”. Perhaps \{CLOSE:10} could means “close this talkbox 10 ticks after all text has been displayed” —indeed, we’ll need a lot more control if we’re to use these things for cutscenes.
Advertisement

1 Comment

Filed under Uncategorized

One Response to [RM-3] Speech Balloons That “Type” Their Message

  1. Pingback: [RM-4] TCP Sockets in RPG Maker VX « Making Games the Hardest Way Possible

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Connecting to %s