Sega Genesis Programming Part 13: Action Table


Multi-page dialogs

Let's skip the commentary and get right to work this time. One small change we need to make is enabling multi-page dialogs. This should have been done back when dialogs were first created but whatever. Let's start by adding a new dialog state flag and a new constant character:


DIALOG_FLAG_TEXT_BUILT=$1A ; set when text to display is built
DIALOG_FLAG_TEXT_OPENING=$1B ; dialog is opening
DIALOG_FLAG_TEXT_DRAWING=$1C ; dialog text is drawing
DIALOG_FLAG_TEXT_OPEN=$1D ; dialog is open
DIALOG_FLAG_TEXT_NEW_PAGE=$1E ; dialog text has another page
DIALOG_FLAG_TEXT_CLOSING=$1F ; dialog is closing
LF=$0A ; '\n' - used to break text into multiple lines
FF=$0C ; form feed - used to break text into pages
ETX=$03 ; end of text 

Let's re-purpose the ^ character to act as a visual indicator there are more pages. We also need some dialog that goes on for more than one page:


dc.b "Dani: `Isn't it time"
dc.b LF
dc.b "to close already?    ^"
dc.b FF
dc.b "Get that guy out of"
dc.b LF
dc.b "here so we can leave.'"
dc.b ETX
align 2

Now the code to process dialogs needs to look for the FF character and draw the next page of text when the player hits a button:


[...]
ProcessDialogTextDrawing:
 btst.l #DIALOG_FLAG_TEXT_DRAWING,d7 ; test if the dialog is opening
 beq.w ProcessDialogTestOpen ; text is not drawing, go to next test
 move.l (MEM_DIALOG_TEXT),a0 ; point a0 to the current character
 move.b (a0),d6 ; copy current character to d6
 cmpi.b #ETX,d6 ; is this the end of the text?
 beq.s ProcessDialogTextEnd ; end of text
 cmpi.b #FF,d6 ; is there another page of text?
 bne.s .1 ; not the new page character, continue to next test
 bset.l #DIALOG_FLAG_TEXT_NEW_PAGE,d7 ; flag there is another page
 bra.s ProcessDialogTextEnd
.1
 cmpi.b #LF,d6 ; is this a new line character
 bne.s .2 ; not a new character
 move.l #(VDP_VRAM_WRITE_A+DIALOG_ROWCOL),(MEM_DIALOG_VPD) ; base address
 add.l #$01020000,(MEM_DIALOG_VPD) ; add 258 to move 2 rows and column
 bra.s .3 ; go to increment text step
.2
 ; update d6 to point to the tile ID
 sub.w #$20,d6 ; subtract 32 to get the character index
 add.w #DIALOG_BASE_TILE,d6 ; add the base tile
 move.l (MEM_DIALOG_VPD),(VDP_CONTROL) ; set VDP address
 move.w  d6,(VDP_DATA) ; copy the character to VPD
 ; draw the next character
 add.l #$00020000,(MEM_DIALOG_VPD) ; move to the next column
.3
 add.l #$0001,(MEM_DIALOG_TEXT) ; move to the next character
 bra.w ExitProcessDialog
ProcessDialogTextEnd:
[...]
ProcessDialogTestOpen:
 ; wait until a button is pressed to clear the dialog
 move.b (MEM_CONTROL_PRESSED),d6 ; copy pressed buttons to d6
 cmpi.w #$0000,d6 ; are any buttons pressed?
 beq.w ExitProcessDialog ; no buttons are pressed, exit
 ; start button shouldn't close the dialog
 andi.w #BUTTON_START_PRESSED,d6 ; test if the start button is held
 bne.w ExitProcessDialog   ; exit if start button is held
 btst.l #DIALOG_FLAG_TEXT_NEW_PAGE,d7 ; test if there is another page 
 beq.s .4 ; branch if new text page is not set
 ;-----------------------------
 ; moving to a new page of text
 ;-----------------------------
 add.l #$0001,(MEM_DIALOG_TEXT) ; move to the next character
 ; reset the drawing location for the dialog text
 move.l #(VDP_VRAM_WRITE_A+DIALOG_ROWCOL),(MEM_DIALOG_VPD) ; base address
 add.l #$00820000,(MEM_DIALOG_VPD) ; add 132 to move 1 row and column
 ; clear out the dialog
 movea.l #PatternDialogFull,a0 ; point a0 to start of dialog patterns
 move.w #DIALOG_BASE_TILE,d0 ; base pattern
 move.w #$0000,d1 ; repeat
 movea.l #(VDP_VRAM_WRITE_A+DIALOG_ROWCOL),a1 ; initial drawing location
 bsr.w DrawTileset ; branch to DrawTileset subroutine
 ; reset flags to force text to start re-drawing
 bset.l #DIALOG_FLAG_TEXT_DRAWING,d7 ; set drawing flag
 bclr.l #DIALOG_FLAG_TEXT_OPEN,d7 ; clear open flag
 bclr.l #DIALOG_FLAG_TEXT_NEW_PAGE,d7 ; clear new page flag
 bra.s ExitProcessDialog ; exit
.4
 bset.l #DIALOG_FLAG_TEXT_CLOSING,d7 ; set closing flag
 bclr.l #DIALOG_FLAG_TEXT_OPEN,d7 ; closing, clear open flag
ProcessDialogTestClosing: 
[...]

Now we have a page of text with a little upside-down triangle thingy:

Dialog page 1

After pressing a button the next page displays. In theory this could support an endless number of pages. With limited screen real estate for text it just might have to.

Dialog page 2

That wasn't a whole lot of work so let's move on to something more painful.

Action table

For this to resemble a real demo we need a system to process actions. Something that figures out "if the player does [X] with thing [Y] then do [Z]". This can't be too complex because there are memory and CPU limits to consider. So I'm going to start by trying a simple table approach.

I don't have a full story for this game idea figured out yet. I'm trying to not get too far ahead of myself. I have a rough outline though and already know it will take place over a number of days. In each day the player will have access to stores (scenes) in the mall. The events that happen will be based on the day of the story.

What I built to manage this is a table that maps a day/scene/action combination to the address of the code to handle it. I could have gone with just day+scene but (a) there was enough room to include the action in the lookup formula and (b) the first thing that would happen in the day/scene code was checking the action anyway.

The action table will have a base address like $B0000 or $F0000. It doesn't matter so long as it's greater than a word and less than the maximum size of a Genesis cartridge. The table lookup values are then constrained to being a word in size. That creates a limit of 16,384 possible day/scene/action combinations. That won't be a problem because I'm having a hard enough time writing up a plot that has more than 10.

The table search order is [DAY]->[SCENE]->[ACTION]. That makes the formula: (Day#*SceneCount*ActionCount*4)+(Scene#*ActionCount*4)+(Action#*4).The *4 part is because the table entries are addresses to code which are long values.

Here's a picture that might make the design easier to digest:

Action table

Hopefully this helps explain it a little. I barely understand how this works myself, it mostly came to me in a vision. The code to implement the table and lookup values goes like:


ROM_ADDR_ACTION_TABLE=$B0000
[...]
;-------------
; action table
;-------------
 org ROM_ADDR_ACTION_TABLE
ActionTable:
 dc.l Day00Scene00Action00
[...]
;----------------------------------------
; compute offset for action table lookups
;----------------------------------------
BuildActionTableOffset:
 move.w MEM_DAY,d7
 mulu.w #SCENE_COUNT_X_ACTION_COUNT_X4,d7
 move.w d7,(MEM_ACTION_TABLE_OFFSET)
 move.w MEM_ACTIVE_SCENE_ID,d7
 mulu.w #ACTION_COUNT_X4,d7
 add.w d7,(MEM_ACTION_TABLE_OFFSET)
 move.w MEM_ACTION_ID,d7
 mulu.w #$4,d7
 add.w d7,(MEM_ACTION_TABLE_OFFSET)
 rts
[...]
TestAButtonPressed: ; test if the player pressed the A button
 move.b (MEM_CONTROL_PRESSED),d6 ; copy pressed buttons to d6
 andi.w #BUTTON_A_PRESSED,d6 ; test if the A button was pressed
 beq.w MainGameLoopUpdateSprites ; A button is not pressed
 ;---------------------------------------------
 ; build event table address and branch to code
 ;---------------------------------------------
 ; clear MEM_CONTROL_PRESSED to prevent state from flipping in loop
 move.w #$0000,(MEM_CONTROL_PRESSED)
 bsr.w BuildNPCObjectList ; update the location of NPCs
 bsr.w FindActionTarget ; find the target of the player's action
 cmpi.w #OBJ_NOTHING,(MEM_ACTION_TARGET_OBJID) ; is the target nothing?
 beq.s NoActionTarget ; branch if no target
 bsr.w BuildActionTableOffset ; build action table offset
 lea ActionTable,a5 ; point to action table
 adda.w (MEM_ACTION_TABLE_OFFSET),a5 ; move to offset location
 move.l (a5),a6 ; a5 has the address of the subroutine to jump to
 jsr (a6) ; jump to location of code to process this event
 bra.w MainGameLoop ; return to start of game loop
[...]

Here's a screenshot of me debugging the code if that's remotely interesting to anyone:

Action table test

This seems like a really small bit of work but in reality it's going to make scripting out the rest of the demo much easier.

Let's give this a quick test by changing an NPC's dialog after you talk to them. As you probably know, one rule of RPGs is you should keep talking to every NPC until they start to repeat themselves:


DialogTextDaniScene0Day0Flag0:
 dc.b "Dani:`Isn't it time"
 dc.b LF
 dc.b "to close already?    ^"
 dc.b FF
 dc.b "Get that guy out of"
 dc.b LF
 dc.b "here so we can leave.'"
 dc.b ETX
 align 2
DialogTextDaniScene0Day0Flag1:
 dc.b "Dani:`Maybe you should"
 dc.b LF
 dc.b "actually talk to him.^"
 dc.b FF
 dc.b "You know, do your job."
 dc.b ETX
 align 2
[...]
Day00Scene00Action00:
 move.l (MEM_DAY_EVENT_FLAGS),d7 ; copy event flags for the day to d7
 move.w (MEM_ACTION_TARGET_OBJID),d6 ; copy action target to d6
 cmpi.w #OBJ_NPC_DANI,d6 ; test target
 bne.s .2 ; branch to next NPC
 ;----------------------------
 ; handle dialog with NPC Dani
 ;----------------------------
 btst.l #$1,d7 ; test if flag 1 is set
 bne .1 ; branch if not
 bset.l #$1,d7 ; set flag 1
 move.l d7,(MEM_DAY_EVENT_FLAGS) ; save updated event flags for the day
 lea DialogTextDaniScene0Day0Flag0,a6 ; load dialog text
 bra.s .4 ; branch to setup dialog display
.1
 lea DialogTextDaniScene0Day0Flag1,a6 ; load dialog text
 bra.s .4 ; branch to setup dialog display
.2
 cmpi.w #OBJ_NPC_MALE_SHOPPER0,d6 ; test target
 bne.s .3 ; branch to display default text
 lea DialogTextMaleShopper0,a6
 bra.s .4 ; branch to setup dialog display
.3
 ; default
 bsr.w ShowDefaultText
 bra.s ExitDay00Scene00Action00
.4
 move.l a6,MEM_DIALOG_TEXT ; copy address to MEM_DIALOG_TEXT
 bsr.w SetDialogOpening ; set the dialog opening flags
ExitDay00Scene00Action00:
 rts

And now the text changes after the first time you talk to your sister, how exciting:

Alternate text

In time I may regret the choice of search order. For example, if there are 10 days in the story it seems wrong to have 10 different flows to handle looking at a piece of static scenery. So this crazy action table idea might work decently for interacting with NPCs but not for looking at scenery.

The "right" flow might be closer to [ACTION]->[ACTION_ITEM]->[ACTION_TARGET] which then checks for day or scene specific rules and otherwise returns a default result. Since action targets could be items or NPCs that number gets big really fast. Let's say in theory this demo grows to a game with 10 days, 20 scenes, and 6 actions. Then the action table has something like ~1200 entries. That's a lot. But if there are 6 actions, 20 items that can be used, and 40 potential targets we're at nearly 5000 entries. Both of those numbers are incomprehensible to me at this point so expect this demo to evolve into something with ~5 days, ~20 scenes, and ~4 actions.

Now that I think about it a bit more - it will be pain to not have default text for objects. So let's add that real quick:


ROM_ADDR_DEFAULT_TEXT_TABLE=$AF000
[...]
;--------------------------
; object default text table
;--------------------------
 org ROM_ADDR_DEFAULT_TEXT_TABLE
DefaultTextTable:
 ; nothing
 dc.l DialogTextNothing
 ; OBJ_SCENE_VB_8BIT
 dc.l DialogText8Bit
 ; OBJ_SCENE_VB_HARDWARE
 dc.l DialogTextHardware
 ; OBJ_SCENE_VB_16BIT
 dc.l DialogText16Bit
 ; OBJ_SCENE_VB_MAGS
 dc.l DialogTextMags
 ; OBJ_SCENE_VB_COUNTER
 dc.l DialogTextCounter
 ; OBJ_SCENE_VB_REGISTER
 dc.l DialogTextRegister
[...]
ShowDefaultText:
 move.w (MEM_ACTION_TARGET_OBJID),d7 ; move target object ID to d7
 andi.w #$0FFF,d7 ; clear the base value
 mulu.w #$4,d7 ; multiply by 4 to get the offset
 lea DefaultTextTable,a6 ; point to default text table
 adda.l d7,a6 ; add offset
 move.l (a6),(MEM_DIALOG_TEXT) ; copy value at a6 to MEM_DIALOG_TEXT
 ; set flags so the dialog displays next time through the main loop
 move.l (MEM_DIALOG_FLAGS),d7 ; copy current dialog state to d7
  bset.l #DIALOG_FLAG_TEXT_OPENING,d7 ; change state to opening
 move.l d7,(MEM_DIALOG_FLAGS) ; save changes made to the game state
 move.l (MEM_GAME_STATE),d7 ; copy current game state to d7
 bset.l #STATE_FLAG_DIALOG,d7 ; set the dialog bit
 move.l d7,(MEM_GAME_STATE) ; copy game state back to d7
 rts
[...]
SetDialogOpening:
 ; sets flags to display the dialog
 move.l (MEM_DIALOG_FLAGS),d7 ; copy current dialog state to d7
 bset.l #DIALOG_FLAG_TEXT_OPENING,d7 ; change state to opening
 move.l d7,(MEM_DIALOG_FLAGS) ; save changes made to the game state
 move.l (MEM_GAME_STATE),d7 ; copy current game state to d7
 bset.l #STATE_FLAG_DIALOG,d7 ; set the dialog bit
 move.l d7,(MEM_GAME_STATE) ; copy game state back to d7
 rts

What's next?

Let's go back to the "plan" from the last article:

1) Dialog between characters - meaning an NPC says something to you and you respond. Maybe you respond with more dialog, maybe you give them an item. That leads to...

2) Inventory management - along with basic stuff like taking and giving items.

3) Game event tracking - we need a way to say "if [X] happened and you talk to character [Y] then do [Z]", I'll probably over-complicate this. I won't call this 100% complete but it's functional enough.

4) Scripted sprite movements - if the ultimate goal is to get a customer to leave a store then there needs to be an animation where they walk away.

5) Adding & removing NPCs from the current scene - the one NPC is hard-coded, there needs to be a way to move NPCs in and out of scenes. The removing part hasn't been needed yet but it's small and will be addressed with #4.

6) To make this look like a real demo I should really create a title screen and ending message.

7) Dozens of things I didn't think of that will all be painful to work out.

So if our little sprite is going to have to show items to NPCs then next up we'll need an inventory system and menus with commands like "take" and whatnot. We also need a way for the player to select items in a dialog or menu. It probably makes sense to build that first. It sounds like some combination of #1 and #2 will be next.

Download

Download the latest source code on GitHub




Related