Where we left off
After 8 articles we have a sprite that can walk around and bump into things while music is playing. We can even pause the game just in case this is too much action for people to handle. Now let's figure out how we're going to let our sprite interact with their environment.
Let's get one thing out of the way first - in the last article I talked about fixing the sprite draw order in this one. I didn't do that. Perhaps I'll fix it next time, although it's more likely I'll continue to ignore it.
Object list
Before our sprite can interact with things in our little store we need to figure out how we want to model objects. The term "objects" is used to generically refer to anything in the room that isn't purely background scenery. NPCs are included in this umbrella.
The first part is figuring out how to store objects. Let's start by assuming all objects have a rectangle that defines their position and size. The reason is we need the ability to determine if a sprite is either looking at, or maybe even standing on, an object.
The map we created is 512x512, even though we're only using 320x224 of it. So we have a couple ways to store an object rectangle:
Not compact:
Word0=Object ID (0-65535)
Word1=x0 (0-65535)
Word2=y0 (0-65535)
Word3=x1 (0-65535)
Word4=y1 (0-65535)
Compact:
Word0=Object ID (0-65535)
Word1[0-8]=x0 (0-511)
Word1[9-15]=width (0-127)
Word2[0-8]=y0 (0-512)
Word2[9-15]=height (0-127)
The 128x128 object size limit is not a huge issue because the display is 320x224, so one object can cover about a quarter of the screen. Objects that need to span more than that could be split into two objects with the same ID in the (unlikely) event they are needed.
We'll also need to create an array to store all these objects. For the sake of this article, the object list has 20 entries with 10 reserved for items in the store and 10 reserved for NPCs. This is more than we'll use for a while. The reason for having a division between items and NPCs is account for NPC values changing as they move. We don't want to update the object list every time an NPC moves, that's just a waste of instructions. Instead we're going to build the NPC list when the player presses a button to interact with whatever they're looking at. For the sake of convenience we'll reserve a spot in the list for NPCs.
Here are all the new constants created for this:
MEM_ACTION_TARGET_OBJID=$00FF0004C ; action target object id
MEM_OBJECT_LIST_OBJS=$00FF0004E ; list of objects in current map
MEM_OBJECT_LIST_NPCS=$00FF0008A ; list of npcs in current map
[...]
; object list constants
OBJ_LIST_LENGTH=$000A ; max of 10 items in the object list
NPC_LIST_LENGTH=$000A ; max of 10 items in the NPC list
OBJ_LIST_LOOP_CTRL=OBJ_LIST_LENGTH+NPC_LIST_LENGTH-1
OBJ_LIST_STRUCT_SIZE=$0006 ; size of the data structure for object list entries
[...]
; object IDs
OBJ_NOTHING=$00000000
;****************************************
; scenery
;****************************************
OBJ_SCENE_VB_8BIT=$1000
OBJ_SCENE_VB_HARDWARE=$1001
OBJ_SCENE_VB_16BIT=$1002
OBJ_SCENE_VB_MAGS=$1003
OBJ_SCENE_VB_COUNTER=$1004
OBJ_SCENE_VB_REGISTER=$1005
;****************************************
; NPCs
;****************************************
OBJ_NPC_DANI=$2000
Somewhere along the way I decided to name the NPC "Dani" which is short for "Danielle". There's not much of a story behind that name. Since the game is set in 1989 I looked at popular baby names in the 60s and 70s to pick some names that were common around 1968-1972. I don't know where this zany experiment will go, but wherever that is will be a place where people have average names.
Next we need to load the objects when the map is initialized. First let's look at the map and figure out where they should go:
The code to load them goes like:
;----------------------------------------
; setup map and object data
;----------------------------------------
InitMap:
[...]
LoadObjectData:
lea MEM_OBJECT_LIST_OBJS,a0 ; store address of object data
;--------------------------------------
; word0=Object ID (0-65535)
; word1[0-8]=x0 (0-511)
; word1[9-15]=width (0-127)
; word2[0-8]=y0 (0-512)
; word2[9-15]=height (0-127)
;--------------------------------------
move.w #OBJ_SCENE_VB_8BIT,(a0)+
; x0=136 width=106 = 1101010 010001000 = D488
move.w #$D488,(a0)+
; y0=136 height=26 = 0011010 010001000 = 3488
move.w #$3488,(a0)+
move.w #OBJ_SCENE_VB_HARDWARE,(a0)+
; x0=242 width=79 = 1001111 011110010 = 9EF2
move.w #$9EF2,(a0)+
; y0=136 height=26 = 0011010 010001000 = 3488
move.w #$3488,(a0)+
move.w #OBJ_SCENE_VB_16BIT,(a0)+
; x0=351 width=90 = 1011010 101011111 = B55F
move.w #$B55F,(a0)+
; y0=136 height=26 = 0011010 010001000 = 3488
move.w #$3488,(a0)+
move.w #OBJ_SCENE_VB_MAGS,(a0)+
; x0=240 width=114 = 1110010 011110000 = E4F0
move.w #$E4F0,(a0)+
; y0=224 height=32 = 0100000 011100000 = 40E0
move.w #$40E0,(a0)+
move.w #OBJ_SCENE_VB_COUNTER,(a0)+
; x0=240 width=80 = 1010000 011110000 = A0F0
move.w #$A0F0,(a0)+
; y0=200 height=16 = 0010000 011001000 = 20C8
move.w #$20C8,(a0)+
move.w #OBJ_SCENE_VB_REGISTER,(a0)+
; x0=320 width=16 = 0010000 101000000 = 2140
move.w #$2140,(a0)+
; y0=162 height=32 = 0100000 010100010 = 40A2
move.w #$40A2,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
LoadNPCData:
bsr.w BuildNPCObjectList
Let's separate the code to update the NPC list because it needs to be called by another subroutine later on:
BuildNPCObjectList: ; note - d6 and d7 are used by caller
lea MEM_OBJECT_LIST_NPCS,a0 ; store address of object data
;--------------------------------------
; word0=Object ID (0-65535)
; word1[0-8]=x0 (0-511)
; word1[9-15]=width (0-127)
; word2[0-8]=y0 (0-512)
; word2[9-15]=height (0-127)
;--------------------------------------
move.w #OBJ_NPC_DANI,(a0)+
move.w (MEM_NPC1_SPRITE_X),d5 ; store x position in d5
subq #$0008,d5 ; buffer area around sprite
add.w (MEM_MAP_POSITION_X),d5 ; adjust for map position
or.w #%0100000000000000,d5 ; append sprite width+buffer to bits [9-15]
move.w d5,(a0)+ ; store word 1
move.w (MEM_NPC1_SPRITE_Y),d5 ; store y position in d5
add.w (MEM_MAP_POSITION_Y),d5 ; adjust for map position
subq #$0008,d5 ; buffer area around sprite
or.w #%0110000000000000,d5 ; append sprite height+buffer to bits [9-15]
move.w d5,(a0)+ ; store word 2
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
move.w #OBJ_NOTHING,(a0)+
move.w #$0000,(a0)+
move.w #$0000,(a0)+
rts
Dialog processing stub
What we need to build is code that:
1) Responds to the A button press: The A button event should fire on the button press not button released. This is how all RPGs on the Genesis work, or at least all that I tried. It's also the recommendation in Sega's documentation that was sent to Genesis developers some 27 years ago.
2) Stops moving the sprites: The A button press should override the direction keys. Control shouldn't be released back to the player until the mode is changed back to exploring.
3) Determines what point on the map the player is looking at: This should be very similar to the code in MoveSprite that determines which cell the sprite is attempting to enter.
4) Checks the object and NPC list to see what, if anything, the player is looking at: This will be a simple brute-force search of the list. If the list was longer we could think about sorting it by something like X or Y position for faster searching.
5) Releases control back to the player when another button is pressed: As usual I'm going to use the original Phantasy Star series as a reference, although most 16-bit RPGs work roughly the same way. In Phantasy Star II the player must press A, B, or C to close a non-interactive dialog. Phantasy Star III improves on this by allowing direction keys to also close a non-interactive dialog. So if you're talking to an NPC and want to move along you can press and hold a direction to close the dialog and start walking. Phantasy Star III gets criticised a lot in comparison to II but there are many things it just plain does better. Anyway, once the dialog is open any button press except Start should close it. Luckily in the last article the pause functionality was added to the beginning of the main game loop and it is handled before entering the code to process dialogs.
Based on how we expect dialogs to work in an RPG, the lifecycle is:
Putting this all into code, we first need to update the main loop to capture the A button press:
MainGameLoop:
[...]
TestDialogOpened: ; test if the player is attempting to open a dialog
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.s MainGameLoopUpdateSprites ; A button is not pressed
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
; clear MEM_CONTROL_PRESSED to prevent dialog state from flipping in loop
move.w #$0000,(MEM_CONTROL_PRESSED)
bra.w MainGameLoop ; return to start of game loop
[...]
Now we need a stub for the dialog lifecycle. As you'll notice, there are a number of placeholders here:
ProcessDialog:
move.w #$2700,sr ; disable interrupts while managing dialogs
move.l (MEM_GAME_STATE),d7 ; copy current game state to d7
ProcessDialogTestTextBuilt:
btst.l #STATE_FLAG_DIALOG_TEXT_BUILT,d7 ; test game state
bne.s ProcessDialogTestOpening ; text is built, move to next test
bsr.w BuildNPCObjectList ; update the location of NPCs
bsr.w FindActionTarget ; find the target of the player's action
; TODO - implement text building
bset.l #STATE_FLAG_DIALOG_TEXT_BUILT,d7 ; flag that text is built
bset.l #STATE_FLAG_DIALOG_TEXT_OPENING,d7 ; change state to opening
ProcessDialogTestOpening:
btst.l #STATE_FLAG_DIALOG_TEXT_OPENING,d7 ; test if the dialog is opening
bne.s ProcessDialogTestOpen ; dialog is not opening, move to next test
; TODO - implement dialog opening animation
bset.l #STATE_FLAG_DIALOG_TEXT_OPEN,d7 ; change state to open when done
ProcessDialogTestOpen:
btst.l #STATE_FLAG_DIALOG_TEXT_OPEN,d7 ; test if the dialog is opening
bne.s ProcessDialogTestClosing ; dialog is not open, move to next test
; 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.s ExitProcessDialog ; no buttons are pressed, exit
ProcessDialogTestClosing:
btst.l #STATE_FLAG_DIALOG_TEXT_CLOSING,d7 ; test if the dialog is opening
bne.s ProcessDialogClearFlags ; dialog is not closing, exit
; TODO - implement dialog closing animation
nop
ProcessDialogClearFlags: ; clear flags when done
bclr.l #STATE_FLAG_DIALOG,d7
bclr.l #STATE_FLAG_DIALOG_TEXT_BUILT,d7
bclr.l #STATE_FLAG_DIALOG_TEXT_CLOSING,d7
ExitProcessDialog:
move.l d7,(MEM_GAME_STATE) ; save any changes made to the game state
move.w #$2000,sr ; re-enable interrupts
rts
The last part is writing code to search for the target object based on what the player sprite is looking at:
FindActionTarget: ; note - d6 and d7 are used by caller
; d4 - store map adjusted player sprite x
move.w (MEM_PLAYER_SPRITE_X),d4 ; move base sprite x
add.w (MEM_MAP_POSITION_X),d4 ; adjust for map position
; d5 - store map adjusted player sprite y
move.w (MEM_PLAYER_SPRITE_Y),d5 ; move base sprite y
add.w (MEM_MAP_POSITION_Y),d5 ; adjust for map position
add.w #SPRITE_COLLISION_Y,d5 ; adjust for collision Y
; determine what the player is looking at
move.w (MEM_PLAYER_SPRITE_DIRECTION),d3 ; copy direction
cmpi.w #DIRECTION_UP,d3 ; test if sprite is facing up
bne.s .1 ; branch if not
subq #SPRITE_COLLISION_UP,d5 ; adjust y
bra.w .4 ; branch to object list search
.1 ; down
cmpi.w #DIRECTION_DOWN,d3 ; test if sprite is facing down
bne.s .2 ; branch if not
add.w #SPRITE_COLLISION_DOWN,d5 ; adjust y
bra.w .4 ; branch to object list search
.2 ; left
cmpi.w #DIRECTION_LEFT,d3 ; test if sprite is facing left
bne.s .3 ; branch if not
subq #SPRITE_COLLISION_LEFT,d4 ; adjust x
bra.w .4 ; branch to object list search
.3 ; right
cmpi.w #DIRECTION_RIGHT,d3 ; test if sprite is facing right
bne.s .4 ; not reachable unless there's a bug in MovePlayer
add.w #SPRITE_COLLISION_RIGHT,d4 ; adjust x
.4
; search object list
lea MEM_OBJECT_LIST_OBJS,a0 ; point a0 to the object list
move.w #OBJ_LIST_LOOP_CTRL,d3 ; use d3 for loop control
;--------------------------------------
; hit test = ((htx>=x1)&&(htx<=x0))&&((hty>y0)&&(hty<y1))
; where
; htx = sprite x adjusted for map scroll
; hty = sprite y adjusted for map scorll
; x0 = right edge of object rect
; x1 = left edge of object rect
; y0 = bottom edge of object rect
; y1 = top edge of object rect
;--------------------------------------
FindActionTargetObjectLoop:
;--------------------------------------
; a0 = word0=Object ID (0-65535)
; a0+2 = word1[0-8]=x0 (0-511) word1[9-15]=width (0-127)
; a0+4 = word2[0-8]=y0 (0-512) word2[9-15]=height (0-127)
;--------------------------------------
move.w (a0),(MEM_ACTION_TARGET_OBJID) ; copy the current object id
cmpi.w #OBJ_NOTHING,(MEM_ACTION_TARGET_OBJID) ; looking at nothing?
beq.w FindActionTargetObjectLoopDbra ; if so loop to next object
; test if sprite x is between left and right edge
move.w (2,a0),d2 ; copy word 1 (x and width)
move.w d2,d1 ; use d1 for width
and.w #%0000000111111111,d2 ; clear bits[9-15]
cmp.w d2,d4 ; (htx>=x1)
blt.w FindActionTargetObjectLoopDbra ; loop if sprite x < object left
; need to shift 9 bits right
lsr.w #$08,d1 ; shift 8
lsr.w #$01,d1 ; shift 1 more
add.w d1,d2 ; add width to left edge to get right edge
cmp.w d2,d4 ; (htx<=x0)
bgt.w FindActionTargetObjectLoopDbra ; loop if sprite x > object right
; test if sprite y is between top and bottom edge
move.w (4,a0),d2 ; copy word 2 (y and width)
move.w d2,d1 ; use d1 for height
and.w #%0000000111111111,d2 ; clear bits[9-15]
cmp.w d2,d5 ; (hty>y0)
blt.w FindActionTargetObjectLoopDbra ; loop if sprite y < object top
; need to shift 9 bits right
lsr.w #$08,d1 ; shift 8
lsr.w #$01,d1 ; shift 1 more
add.w d1,d2 ; add height to top edge to get bottom edge
cmp.w d2,d5 ; (hty<y1)
blt.w ExitFindActionTarget ; if last test passes then we have a hit
FindActionTargetObjectLoopDbra:
adda.w #OBJ_LIST_STRUCT_SIZE,a0 ; move to next object list entry
dbra d3,FindActionTargetObjectLoop ; decrement and loop
ActionTargetNotFound:
move.w #OBJ_NOTHING,(MEM_ACTION_TARGET_OBJID) ; nothing by default
ExitFindActionTarget:
rts
Since we haven't created the dialogs yet, the only way to see if this works is by checking if MEM_ACTION_TARGET_OBJID ($00FF0004C) is updated with the expected value:
What's next?
The one really big thing that's missing from all this - a dialog that displays what the sprite is looking at. I think I'll save that for next time. I keep trying to bite off small chunks of code at time and dialogs will be the next chunk. Thinking through it there are a few things that need to happen:
1) Creating a font.
2) Some kind of rules to determine which string is displayed based on the object and other game variables.
3) The actual drawing of the dialog. Having them pop-up instantly in one frame would look awful so there needs to be code to expand & collapse them.
Sounds like we have something fun to work on next time...
Download
Download the latest source code on GitHub
Related