Episode 17: Text UI

Episode 17: Text UI

We're going to take a little bit of a break from graphics and work on adding one of the defining features of the original King's Quest – a text interface underneath the scene.

Mama bird's not gonna be happy!

In King's Quest 1, you move Sir Grahame around and navigate between screens with the arrow keys, but much more rich interaction with the game was available via a text prompt where you could type commands. These commands consisted of simple verbs or verb-noun pairs such as "take dagger" or "look at rock" or "swim".

We want to add the same sort of interface to our game. We're not at the level of processing and acting on the commands yet, that will come later; but we can at least add the ability to type in commands and respond to them.

The first thing we need to do is rethink our keyboard handling. We first added player movement way back in Episode 8, and in my naivete, I chose a low-level method of keyboard processing – overriding the built-in BIOS keyboard handler (INT 9h) with my own. This allowed us to see when a key was pressed and released as two separate events – good for gaming (hold a key to move, let go of the key to stop), but not so good for processing text.

Instead, we'll let the BIOS do the work for us. The built-in INT 9h processes low-level keyboard events into keystrokes and sticks them in a buffer to be read by an application. So we'll leave the default handler in place and just check the buffer.

So remove the routines install_keyboard_handler and restore_keyboard_handler; we won't be needing those. And change process_keys to this:

; Process any keystrokes in the keyboard buffer, handling ESC
; to quit the program and cursor keys to move the player.
process_keys:
  mov ah, 1     ; Check for keystroke.  If ZF is set, no keystroke.
  int 16h
  jnz .get_keystroke
  ret
  
  .get_keystroke:
  mov ah, 0     ; Get the keystroke. AH = scan code, AL = ASCII char
  int 16h
    
  cmp ah, KEYCODE_ESC             ; Process ESC key
  jne .test_dir_keys
  mov byte [is_running], 0
  ret
  
  .test_dir_keys:
    cmp ah, KEYCODE_LEFT
    je .toggle_walk
    cmp ah, KEYCODE_RIGHT
    je .toggle_walk
    cmp ah, KEYCODE_UP
    je .toggle_walk
    cmp ah, KEYCODE_DOWN
    je .toggle_walk
    ret

  .toggle_walk:
    cmp [player_walk_dir], ah
    je .stop_walking
    mov [player_walk_dir], ah
    ret
  .stop_walking:
    mov byte [player_walk_dir], KEYCODE_NONE
    ret

INT 16h, 1 checks if the buffer contains a keystroke we haven't seen yet. If so (indicated by the zero flag NOT being set), we call INT 16h, 0 to actually get the keystroke into AX (AH = scan code and AL = ASCII code). Now we can test the scan code in AH against our constants for ESC and the arrow keys.

We've also made our player movement more like King's Quest by introducing a toggling function. Pressing a cursor key once starts the character moving in that direction, and pressing the same key again stops him.

If you remember in the last episode, we decreased the height of the screen from 200 to 168. This made things easier for our priority (depth) computations since the 14 priority values divides evenly into 168, but it also opens up an area at the bottom of the screen we can use to implement our prompt. In 320x200 graphics mode, 25 lines of text can fit on a screen. Each line of text are 8 pixels high. This gives us 4 lines to work with under our scene. Let's print a text prompt ('>') on the first of the four lines:

; Move cursor to text window and print a prompt
sub bh, bh       ; Set page number for cursor move (0 for graphics modes)
mov dx, 1500h    ; line 21 (0x15), col 0 (0x0)
mov ah, 2        ; Call "set cursor"
int 10h
print text_prompt

INT 10h, 2 sets the cursor position, then we use our trusty print macro to print the prompt. We'll also modify process_keys to print any non-directional input we receive straight to the screen:

...
  .test_dir_keys:
    cmp ah, KEYCODE_LEFT
    je .toggle_walk
    cmp ah, KEYCODE_RIGHT
    je .toggle_walk
    cmp ah, KEYCODE_UP
    je .toggle_walk
    cmp ah, KEYCODE_DOWN
    je .toggle_walk
    
  .processChar:
    mov ah, 0x0e
    mov bl, 7     ; Text color
    int 10h
    ret
...

INT 10h, e write the received character to the screen. There are other interrupts that write a character, but this one advances the cursor for us and handles special keys (which will be important in a minute).

Run the program. We get our prompt! Now smash that keyboard and see what happens:

We're able to type, and when the cursor hits the end of the line it advances to the next one. That's all given to us free by INT 10h, e. But when we get to the end of the last line, the whole screen scrolls up! We'll have to do a little work here.

Luckily, there's a BIOS routine that will scroll only part of the screen: INT 10h, 6. But to use it, we'll have to monitor the cursor position ourselves. Let's grab the cursor position before processing input with INT 10h, 3:

push ax
mov ah, 3     ; Get cursor position.  We only care about column, in DL.
xor bh, bh
int 10h
pop ax        ; Now AH = key scan code, AL = ASCII char, DL = cursor column.  

Let's also limit our input length to one line. If we hit column 39 (the end of the line), just don't process any more characters. We also need to handle the Enter key, by printing some type of acknowledgement, then another prompt:

  cmp ah, KEYCODE_ENTER
  jne .next
  call advance_to_next_line
  print text_version
  call advance_to_next_line
  print text_prompt
  ret

advance_to_next_line will be interesting. We want to send a carriage return to move the cursor back to column 0.

We'll send a carriage return to advance to the next line, then we'll check the current row. If we're not on the bottom line, go ahead and send a line feed to advance the row by one, but if we are, use INT 10h, 6 to scroll our text up by 1 line:

; We can't send a line feed - if the cursor is already on the last line, it'll scroll the whole screen. So:
; - Send a carriage return.
; - Check cursor row: if 24 or less, send a line feed.
; - Otherwise, scroll just the text area.
advance_to_next_line:
  mov ax, 0e0dh
  int 10h      ; Send carriage return
  mov ah, 3     
  xor bh, bh
  int 10h      ; Get cursor position. Row is in DH.
  cmp dh, 24
  jge .scroll
  mov ax, 0e0ah
  int 10h      ; Send line feed
  ret
  .scroll:
    mov ax, 0601h  ; Scroll by 1 line
    xor bh, bh     ; No attributes
    mov cx, 1500h ; Upper left = row 20, col 0
    mov dx, 1827h ; Lower right = row 24, col 39
    int 10h
    ret

Notice that INT 10h, 6 takes a 'window' to scroll, defined by an upper left and lower right row/column. Complicated, but works great!

There's one more thing we have to do: Backspace handling. If you noticed, backspace seems to do nothing, but if you start typing again you'll notice it has backed up the cursor. Let's provide the expected behavior by also erasing the character under the cursor:

; Backspace only backs up the cursor. To actually clear the character we'll
; send three characters: backspace, space, backspace.
process_key_backspace:
  mov ax, 0e08h
  mov bl, 7
  int 10h
  mov ax, 0e20h
  mov bl, 7
  int 10h
  mov ax, 0e08h
  mov bl, 7
  int 10h
  ret

But we don't want to back up over our prompt and into the previous line, so only apply the backspace if the cursor column is 3 or greater:

.testBackspace:
cmp al, KEYCHAR_BACKSPACE
jne .processChar
cmp dl, 3     ; First 2 characters are the prompt, so only jump if we're at >= 3
jl .done
call process_key_backspace
ret

And that's it! Input works (to be processed in a future episode), Enter and Backspace work, the cursor keys move the character, and the Escape key still works to end the program.

As always, the complete source is available on Github: https://github.com/josh2112/pcjr-asm-game/tree/episode-17