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.

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