= Writing Games in Assembly Language == Introduction At the end of [[SD-8516 Stellar BASIC]] we wrote a game called [[sdb:robots|ROBOTS.BAS]]. This was a fun take on the classic CHASE or 'robots' game from the bsd games collection. Now, for our study of Assembly Language, let's write a similar game: 'robots.asm'. Don't worry if you're not familiar with the BASIC version of this program; everything will be explained here. However, if this is your first time writing an assembly language program, you might want to familiarize yourself with the architecture of the SD-8516 first: * [[SD-8516 Assembly Language]] Onwards and upwards. == First Steps: Assembling and Running a Program Just like BASIC programs have line numbers, or C programs have #include statements, an assembly language program should start with an ''.address'' statement. This will tell the assembler where to start assembling the program. This enables us to use labels like ''start:'' and ''message:''. The assembler will calculate label addresses relative to this value while assembling the program. .address $000100 start: LDELM @message LDAH $18 INT $10 RET message: .bytes "It works!", 13, 10, 0 If you enter this code into ed and save it as robots.asm, then you can do: as robots.asm robots To run the program, just DLOAD (or LOAD) it and type "SYS 256". This can be a bit confusing; why 256? Without instructions, people might not know what to do. There are three options. * 1. Tell people they have to type SYS and a number. Mysterious. * 2. use ''.address $030100'' (the default ''SYS'' location). People can run it with ''SYS''. * 3. Include a BASIC stub so people can type ''RUN''. For this program I will demonstrate option #3, although #2 is probably just as common. Our program now becomes: .address $000100 ; BASIC stub: 10 SYS 269 .bytes $FB, $0A, $00 .bytes "SYS 269", $00 .bytes $00, $00 ; Entry point at $00010D (decimal 269) start: LDELM @message LDAH $18 INT $10 RET message: .bytes "It works!", 13, 10, 0 === Using the editor Enter this into ed by typing: f robots.asm a Enter the program then type ''.'' on a line by itself. Then type ''w'' to save and ''q'' to quit. Now you can assemble the program. === Assembly To assemble a program named ''robots.asm'' in d-tank, type: as robots.asm r === LOAD and RUN Finally, enter dload r After you ''dload r'' you can type LIST. It will show: 10 SYS 269 Now you can RUN the program. This is a fun and convenient way to let users launch your assembly program. == Game 1: Robot Our first game will be a version of ''robots'' called "Robot", since there's only one robot. === Part 1. Program Structure We begin any project by mapping out the structure of the program and talking about the subroutines, variables, and main ideas behind the code. For ''robots'' we need: * A game loop * A subroutine that draws the map * A subroutine to get player input * A subroutine to move the robot * Collision detection ==== Initialization Just like in the BASIC version, we can start by drawing the map. First, let's define our variables and then the map drawing function. We will include a simple main loop whose only job is to call draw_map once. ; Define variables. ; This section uses memory-location variables. $00 is memory location $00. .equ PX $00 ; player x .equ PY $01 ; player y .equ RX $02 ; robot x .equ RY $03 ; robot y .address $000100 ; BASIC stub: 10 SYS 269 .bytes $FB, $0A, $00 .bytes "SYS 269", $00 .bytes $00, $00 start: ; First, let's initialize our variables. LDAL #19 STAL [@PX] ; Store 19 as initial player X location. LDAL #11 STAL [@PY] ; Store 11 as player's initial Y LDAL #1 STAL [@RX] STAL [@RY] ; The robot starts at 1,1 for now, main_loop: CALL @draw_map RET Now that we have initialized our main variables and set up a program loop we are ready for the ''draw_map'' subroutine. === Part 2. Draw the Map Like in the BASIC version, the ''draw_map'' subroutine will begin by clearing the screen. So the first two things we do are we replicate VSTOP and CLS from BASIC: .equ VIDEO_MODE $01EF00 ; This defines VIDEO_MODE as a memory location variable. draw_map: ; VSTOP LDAL $81 STAL [@VIDEO_MODE] ; CLS LDAH $10 ; CLS INT $10 In assembly, we can't easily call a BASIC command, so we do things slightly differently. Setting video mode to $81 sets us up in mode 1 but stops video updates (because bit 7 is set). So $81 means "mode 1, but set bit 7 so we don't update the screen". This is normally done to stop screen tearing caused by drawing during an update. But here we're just doing it for academic reasons since assembly is so fast the screen won't have time to update until we're done. Secondly, INT $10 AH=$10 is the clear screen function. We can call that as an interrupt helper here. ==== Drawing the border To draw the border we need to set up a loop that draws stars on the top and bottom, left and right, just like in BASIC. Here we will examine the top and bottom loop: ; Draw top/bottom border: '*' at (0..39, 0) and (0..39, 24) LDBL #'*' ; Load character into BL LDXL #0 ; set X location dm_top_bottom: LDYL #0 ; set Y location LDAH $11 ; write char AL at XL, YL INT $10 This simply loads the character, sets the x and y locations, and calls write_char_at_xy via INT $10. LDYL #24 LDAH $11 ; write char (on the bottom of the screen this time) INT $10 INC XL CMP XL, #40 JNC @dm_top_bottom Here's the loop: After the top and bottom characters are drawn, we INC XL (which is the column marker). Then we CMP XL, #40. This means "check if it's under 40"-- i.e. 0-39. If we are on 39, we need to draw again. If we just drew on column 39 and then we INC XL to 40, that means don't continue the loop, and we fall-through to the next function; drawing on the left and right sides. ; Draw left/right border: '*' at (0, 0..24) and (39, 0..24) LDBL #'*' LDYL #0 dm_left_right: LDXL #0 LDAH $11 ; write char INT $10 LDXL #39 LDAH $11 ; write char INT $10 INC YL CMP YL, #25 JNC @dm_left_right This code works just like the top and bottom but on the left and right sides. Instead of looping from 0 to 39 on XL, we will loop from 0 to 24 on YL. As before, we CMP to see if YL is 25. If it is, we fall through. A simple loop, in essence the exact same loop technique we used to draw the map in BASIC. ==== Draw the player and robots ; Draw player '@' LDBL #'@' LDXL [@PX] LDYL [@PY] LDAH $11 ; write char INT $10 ; Draw robot 'r' LDBL #'r' LDXL [@RX] LDYL [@RY] LDAH $11 ; write char INT $10 ; VSTART LDAL #1 STAL [@VIDEO_MODE] RET Finally we draw the player and the robot, and turn video updates back on (Set mode to 1, but without bit 7 set). As we are done drawing the map, we can now RET. If you save, assemble and run the program now you will see it draws the border, player and robot before exiting. It's working! === Part 3: Player Input Let's begin part 3 by adding a new subroutine to our main loop. Our main loop now becomes: main_loop: CALL @draw_map CALL @get_input RET ==== get_input The primary goal of this section is to get input from the player and deal with it. We can do this by using the getkey function: get_input: LDAH $02 ; getkey() INT $10 In this case, the function specification for AH=$02, INT 0x10 is: ; ============================================================================ ; AH=02h - Read Character (Blocking) ; Input: None ; Output: AL = ASCII character ; B = number of keys that were in buffer before this call ; K = keyboard flags at time of press ; Note: X, Y preserved for cursor position ; This function BLOCKS until a key is pressed ; ============================================================================ As we can see, this function returns the ASCII code of the key the player will press in ''AL''. Thus, we can deal with the player's commands. ==== uppercase or lowercase? The player might type an uppercase command or a lowercase command. Let's normalize the keys to uppercase before processing them: ; Uppercase: if AL >= 'a', it must be an uppercase character (or an invalid command). CMP AL, #97 JNC @gi_check_keys ; AL < 'a', skip CMP AL, #123 JC @gi_check_keys ; AL >= '{', skip SUB AL, #32 gi_check_keys: ... CMP works as follows: CMP A, B ; if A >= B, then set carry Therefore, CMP AL, #97 ; if AL is greater or equal to #97 ('a') then set carry So therefore if carry is NOT set, skip past the SUB command. Or else (if it's a lowercase ASCII) subtract 32 to convert from lowercase to uppercase. The second compare has a protective function. CMP AL, #123 ; if AL >= 123, set carry. ; If it's '{' or anything after, skip: JC @gi_check_keys ; Jump if Carry = Jump if AL > 'z' (al >= '}') So after these two checks, the only values that reach SUB AL, #32 are exactly the bytes from 97 to 122 inclusive; i.e. 'a' to 'z'. Visual summary: ^ AL Value ^ Meaning ^ What Happens ^ | < 97 | before ''a'' | Skipped. | | 97-122 | ''a'' to ''z'' | SUB #32 becomes 'A' to 'Z'. | | >= 123 | after 'z'\\ //'}' and up// | Skipped. | ==== Processing input We will use the HJKL convention: gi_check_keys: CMP AL, #'H' JZ @move_left CMP AL, #'J' JZ @move_down CMP AL, #'K' JZ @move_up CMP AL, #'L' JZ @move_right CMP AL, #'Q' JZ @quit_game RET This is easy enough; after a CMP, if the values are equal then the zero flag is set. We will use JZ (jump if zero flag is set) to go to the correct routine. ==== Moving the Player move_left: LDAL [@PX] ; load variable PX into register AL. DEC AL ; X = X - 1 (move to the left!) CMP AL, #1 ; bounds check. Is the player on the border? JC @ml_ok ; AL >= 1, so it's ok. skip to ml_ok, LDAL #1 ; If we reach here the player was on the border; set position to 1. ml_ok: STAL [@PX] ; pass! Save the player's location. RET This is the move_left command. We begin by loading the player's X location into AL and executing the move. After a bounds check, we save the variable. All of the other moves operate this way; we attempt to move the player and then check for bounds: move_down: LDAL [@PY] INC AL CMP AL, #23 JNC @md_ok ; AL < 23 (i.e. <= 22), keep LDAL #23 md_ok: STAL [@PY] RET move_up: LDAL [@PY] DEC AL CMP AL, #1 JC @mu_ok ; AL >= 1, keep LDAL #1 mu_ok: STAL [@PY] RET move_right: LDAL [@PX] INC AL CMP AL, #39 JNC @mr_ok ; AL < 39 (i.e. <= 38), keep LDAL #38 mr_ok: STAL [@PX] RET ==== Quit Command The player can also quit the game by pressing q: quit_game: ; CLS LDAH $10 INT $10 ; Print "QUIT GAME" LDBLX @msg_quit LDAH $66 INT $05 LDAH $64 INT $05 RET msg_quit: .bytes "QUIT GAME", 0 ==== Moving the Robot Moving the robot is similar to the player, except that we do not have commands from the robot (it has no keyboard to type), so we must simply try to move it towards the player. We will begin by determining if the player is to the left or the right, and then move left or right towards the player. Then we will check if the player is above or below us and then move up or down towards the player. move_robot: ; X axis: move RX toward PX LDAL [@RX] LDBL [@PX] CMP AL, BL JZ @mr_check_y ; RX == PX, skip ;; If carry is set, then AL is bigger than BL and we should decrease RX's position. JC @mr_x_dec ; RX >= PX (but not equal), so RX > PX ; fall-through RX < PX ; In this case since the RX is less than PX, we should increase it to move towards the player. INC AL JMP @mr_x_done mr_x_dec: DEC AL mr_x_done: STAL [@RX] ; save the robot's X position for draw_map. mr_check_y: ; Y axis: move RY toward PY LDAL [@RY] LDBL [@PY] CMP AL, BL JZ @mr_done ; RY == PY, skip JC @mr_y_dec ; RY > PY ; RY < PY INC AL JMP @mr_y_done mr_y_dec: DEC AL mr_y_done: STAL [@RY] ; save the robot's y position for draw_map. mr_done: RET After checking the relative location of the player vs. the robot, the robot attempts to move towards the player. After this, you can update the main loop: main_loop: CALL @draw_map CALL @get_input CALL @move_robot RET === Part 4. Win condition The game is over when the robots collide with you. Here's the new main loop: main_loop: CALL @draw_map CALL @get_input CALL @move_robot ; Check collision: PX==RX && PY==RY ; The collision check falls through to game_over only if both axes match, otherwise loops back. LDAL [@PX] LDBL [@RX] CMP AL, BL JNZ @main_loop LDAL [@PY] LDBL [@RY] CMP AL, BL JNZ @main_loop JMP @game_over RET That little bit at the end checks for collision. As an exercise to the reader, you can try to move it into a subroutine. Next let's write the game-over ending: game_over: ; CLS LDAH $10 INT $10 ; Print "OH NO! THE ROBOT CATCHES YOU." LDBLX @msg_game_over1 LDAH $66 INT $05 LDAH $64 ; newline INT $05 ; Print "GAME OVER." LDBLX @msg_game_over2 LDAH $66 INT $05 LDAH $64 INT $05 RET ; ============================================================================ ; String data ; ============================================================================ msg_game_over1: .bytes "OH NO! THE ROBOT CATCHES YOU.", 0 msg_game_over2: .bytes "GAME OVER.", 0 msg_quit: .bytes "QUIT GAME", 0 At this time, your ''robots.asm'' has the same functionality as BASIC's ''ROBOTS.BAS''. In fact it's a little bit cleaner; instead of having to press ESC to quit the BASIC version, you have cleanly implemented a quit command. === Part 5. Music and Sound Let's add a little sound effect to the game (just like in BASIC!) robot_beep: LDELM @robot_sfx LDAH $52 INT $11 RET robot_sfx: .bytes "T120 W1 V5 O2 L24 B", 0 Now add ''CALL @robot_beep'' right after ''CALL @move_robot'' in the main loop: main_loop: CALL @draw_map CALL @get_input CALL @move_robot CALL @robot_beep ... There, now you can PLAY music strings just like in BASIC. This is good! === robots.asm Complete Listing // robots.asm ; Variables .equ PX $00 ; player X .equ PY $01 ; player Y .equ RX $02 ; robot X .equ RY $03 ; robot Y .equ VIDEO_MODE $01EF00 ; Current video mode (1 byte) ; Address statement. .address $000100 ; BASIC stub: 10 SYS 269 .bytes $FB, $0A, $00 .bytes "SYS 269", $00 .bytes $00, $00 ; Entry point at $00010D (decimal 269) start: ; Initialize variables LDAL #19 STAL [@PX] LDAL #11 STAL [@PY] LDAL #1 STAL [@RX] STAL [@RY] main_loop: CALL @draw_map CALL @get_input CALL @move_robot CALL @robot_beep ; Check collision: PX==RX && PY==RY ; The collision check falls through to game_over only if both axes match, otherwise loops back. LDAL [@PX] LDBL [@RX] CMP AL, BL JNZ @main_loop LDAL [@PY] LDBL [@RY] CMP AL, BL JNZ @main_loop JMP @game_over RET ;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Draw the map. ;;;;;;;;;;;;;;;;;;;;;;;;;;;; draw_map: ; VSTOP LDAL $81 STAL [@VIDEO_MODE] ; CLS LDAH $10 ; CLS INT $10 ; Draw top/bottom border: '*' at (0..39, 0) and (0..39, 24) LDBL #'*' LDXL #0 dm_top_bottom: LDYL #0 LDAH $11 ; write char AL at XL, YL INT $10 LDYL #24 LDAH $11 ; write char INT $10 INC XL CMP XL, #40 JNC @dm_top_bottom ; Draw left/right border: '*' at (0, 0..24) and (39, 0..24) LDBL #'*' LDYL #0 dm_left_right: LDXL #0 LDAH $11 ; write char INT $10 LDXL #39 LDAH $11 ; write char INT $10 INC YL CMP YL, #25 JNC @dm_left_right ; Draw player '@' LDBL #'@' LDXL [@PX] LDYL [@PY] LDAH $11 ; write char INT $10 ; Draw robot 'r' LDBL #'r' LDXL [@RX] LDYL [@RY] LDAH $11 ; write char INT $10 ; VSTART LDAL #1 STAL [@VIDEO_MODE] RET ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Get input from the player. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; get_input: ; Blocking GETKEY -> AL LDAH $02 INT $10 ; Uppercase: if AL >= 'a' and AL < 'z'+1, subtract 32 CMP AL, #97 JNC @gi_check_keys ; AL < 'a', skip CMP AL, #123 JC @gi_check_keys ; AL >= '{', skip SUB AL, #32 gi_check_keys: CMP AL, #'H' JZ @move_left CMP AL, #'J' JZ @move_down CMP AL, #'K' JZ @move_up CMP AL, #'L' JZ @move_right CMP AL, #'Q' JZ @quit_game RET move_left: LDAL [@PX] DEC AL CMP AL, #1 JC @ml_ok ; AL >= 1, keep LDAL #1 ml_ok: STAL [@PX] RET move_down: LDAL [@PY] INC AL CMP AL, #23 JNC @md_ok ; AL < 23 (i.e. <= 22), keep LDAL #23 md_ok: STAL [@PY] RET move_up: LDAL [@PY] DEC AL CMP AL, #1 JC @mu_ok ; AL >= 1, keep LDAL #1 mu_ok: STAL [@PY] RET move_right: LDAL [@PX] INC AL CMP AL, #39 JNC @mr_ok ; AL < 39 (i.e. <= 38), keep LDAL #38 mr_ok: STAL [@PX] RET ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Move the robot. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; move_robot: ; X axis: move RX toward PX LDAL [@RX] LDBL [@PX] CMP AL, BL JZ @mr_check_y ; RX == PX, skip JC @mr_x_dec ; RX >= PX (but not equal), so RX > PX ; RX < PX INC AL JMP @mr_x_done mr_x_dec: DEC AL mr_x_done: STAL [@RX] mr_check_y: ; Y axis: move RY toward PY LDAL [@RY] LDBL [@PY] CMP AL, BL JZ @mr_done ; RY == PY, skip JC @mr_y_dec ; RY > PY ; RY < PY INC AL JMP @mr_y_done mr_y_dec: DEC AL mr_y_done: STAL [@RY] mr_done: RET ;;;;;;;;;;;;;;;;;;;;;;;; ;; Game Over. ;;;;;;;;;;;;;;;;;;;;;;;; game_over: ; CLS LDAH $10 INT $10 ; Print "OH NO! THE ROBOT CATCHES YOU." LDBLX @msg_game_over1 LDAH $66 INT $05 LDAH $64 ; newline INT $05 ; Print "GAME OVER." LDBLX @msg_game_over2 LDAH $66 INT $05 LDAH $64 INT $05 RET ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Player quit game ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; quit_game: ; CLS LDAH $10 INT $10 ; Print "QUIT GAME" LDBLX @msg_quit LDAH $66 INT $05 LDAH $64 INT $05 RET robot_beep: LDELM @robot_sfx LDAH $52 INT $11 RET ; ============================================================================ ; String data ; ============================================================================ msg_game_over1: .bytes "OH NO! THE ROBOT CATCHES YOU.", 0 msg_game_over2: .bytes "GAME OVER.", 0 msg_quit: .bytes "QUIT GAME", 0 robot_sfx: .bytes "T120 W1 V5 O2 L24 B", 0 == Game 2: Rogueima Game programming is not that difficult, as long as you establish the scope of your game and understand all the individual parts, it can be considered a robotic, mechanical exercise of coding. Even if there are some parts you don't understand, you can learn-as-you-go so long as you have a conception of what it should be like. But it's important not to bite off more than you can chew. Let's continue on our journey of learning Assembly for the SD-8516 by moving from ''Robot'' towards ''Rogue.'' === Scope and Planning As always, the first step is scope and planning. What exactly are we trying to accomplish with this game? And, why Assembly, why not BASIC? The simple answer is that in a typical roguelike game, although it is character based, it requires data structures and computations that are difficult to perform in BASIC. In it's current form. Stellar BASIC is not set up to really make a big, professional-quality game. That will change in the future as it gets improved over time, but for now, if you want to make something 'big' like a clone of one of the great classic games, you are probably better off with Assembly. Let's hope I can get a C compiler up someday and that might change for good. Not that there's anything wrong with assembly, but it takes twice as long to really get anything done. It ends up twice as fast and half the size, though, so, it's not all bad! Ok, here's what we need. * Everything in the ''robot.asm'' game * A status display showing our character information * A map that we can explore * Some form of 'fog of war' or 'lighting' -- not required, but nice. * The ability for monsters to randomly appear * The ability to fight and defeat monsters * The ability to pick things up and use them * The ability to view an inventory * The ability to save your game Once we have all these things, the game will be considered finished. Beyond the above, there is actually quite a lot of work that would need to be done to make a game like NetHack (or even, like bsdgames' rogue), but that's okay. Baby steps. === Step 1. Drawing the Screen The easiest first step is to draw a status bar. In classic rogue, the status bar is on the bottom of the screen. But this means the map is now 80 x 23 (or 80x22 on some displays) characters, and that is incredibly unbalanced. Let's try a different approach and put the status on the right. This is how Temple of Apshai did it, not to mention Ultima 4, and many other games. The Bard's Tale series, technically, is an example of the other style; having the status on the bottom. Second, let's use Mode 6 for this game, since it is the classic terminal interface you would expect from a game like Rogue or NetHack. ; ************************************** ; * * * ; * * * ; * * * ; * *********** ; * * * ; * * * ; * * * ; * * * ; * * * ; ************************************** The above is not to scale, but is the idea. The left side will hold the map, and the right side will hold the status. Status, for now, will include Name, Strength, and Hit Points. That's all we need to start. Therefore we will need new variables to hold Name, Strength, and Hit Points, and we will need to write that into the status bar after we draw the map border. To the front of the code we will add variable space for the player's stats: .equ PX $00 ; player X .equ PY $01 ; player Y .equ RX $02 ; robot X .equ RY $03 ; robot Y .equ player_name $04 ; max 16 characters. .equ pname_zero $15 ; zero-terminated .equ player_hp $16 ; max HP 255, 0=dead. .equ player_str $17 ; one byte to hold strength An interesting question is what things like "strength" and "hit points" mean. We could just as well call them "hearts" or "health" or "attack power" or "skill". We could use the old D&D idea of 3d6 (3 to 18) but maybe for a different game. Here we just set strength and health to 10, and worry what that means in game terms later on. Therefore, our initialization becomes: init_game: LDA #10 STAL [@player_str] STAL [@player_hp] You may notice we defined the variables differently here. Above, we added a .bytes imperative to reserve space. The label above the .bytes becomes the label of the memory address where those bytes are assembled. Instead, we could have defined the variables like this: .equ player_str $00 ; zero page address 0 .equ player_hp $01 ; zero page address 1 .equ player_name $02 ; 16 bytes + 1 zero starting at $02 .equ pname_zero $12 ; this must always be a zero even if there are earlier zeroes. .equ free_space $13 ; free space starts at $13 Next, when drawing the map, we will use the following modified routine: draw_map: ; VSTOP LDAL $86 ; Mode 6 VSTOP STAL [@VIDEO_MODE] ; CLS LDAH $10 ; CLS INT $10 ; Draw top/bottom border: '*' at (0..79, 0) and (0..79, 24) LDBL #'*' LDXL #0 dm_top_bottom: LDYL #0 LDAH $11 ; write char AL at XL, YL INT $10 LDYL #24 LDAH $11 ; write char INT $10 CMP XL, #59 ; if XL >= 10, then set carry. JNC @dm_skip_ms ; So if it's not, then don't draw the middle separator char. LDYL #10 ; status/text window separator LDAH $11 ; write char INT $10 dm_skip_ms: INC XL CMP XL, #80 ; Mode 6 is 80 chars wide. JNC @dm_top_bottom ; Draw left/right borders: '*' at (0, 0..24) and (59, 0..24) and (79, 0..24) LDBL #'*' LDYL #0 dm_left_right: LDXL #0 ; left border LDAH $11 ; write char INT $10 LDXL #59 ; middle border LDAH $11 ; write char INT $10 LDXL #79 ; right side border LDAH $11 ; write char INT $10 INC YL CMP YL, #25 JNC @dm_left_right ; Draw player '@' LDBL #'@' LDXL [@PX] LDYL [@PY] LDAH $11 ; write char INT $10 ; Draw robot 'r' LDBL #'r' LDXL [@RX] LDYL [@RY] LDAH $11 ; write char INT $10 ; VSTART LDAL #6 ; Turn off VSTOP STAL [@VIDEO_MODE] RET The essential changes are, we section off the map differently, and we VSTOP/VSTART based on Mode 6. Because we're using Mode 6, we need to initialize into Mode 6. Start becomes: start: ; Set mode 6. LDAH $40 ; Set video mode LDAL #6 ; to 6 INT 0x10 CALL @init_game JMP @main_loop ; start game init_game: ; Initialize variables LDAL #19 STAL [@PX] LDAL #11 STAL [@PY] LDAL #1 STAL [@RX] STAL [@RY] LDAL #10 STAL [@player_str] STAL [@player_hp] CALL @get_player_name RET Next, the game won't be any fun unless we can name our hero. Let's ask the player their name. To do this, we will need an INPUT function. Let's use IO_INPUT: ; ---------------------------------------------------------------------------- ; AH=$68: IO_INPUT - Read line of input from user ; Input: None ; Output: ELM = pointer to input string (null-terminated, in @PATB_TBUF) ; B = numeric value (parsed via atoi) ; CF = 0 on success, 1 on empty input ; Notes: Reads characters until ENTER (13). ; Supports backspace for editing. ; Echoes characters to screen. ; ENTER is not echoed. ; Max input length limited by PATB_TBUF size. ; ---------------------------------------------------------------------------- get_player_name: LDELM @what_is_your_name LDAH $10 ; write string INT 0x10 LDAH $68 ; IO_INPUT INT 0x05 ; player name is now at ELM. ; Ensure player name is no longer than 16 characters. MOV FLD, ELM ADD FLD, #17 LDAL #0 STAL [FLD] ; Copy player name into variable space. LDFLD @player_name name_copy_loop: LDAL [ELM] STAL [FLD] INC ELM INC FLD JNZ @name_copy_loop RET ;; return. what_is_your_name: .bytes "What is your name? ", 0 ==== Drawing the Status Area When a function gets too big I like to break it up into smaller functions, that way it's easier to think about the program. It seems draw_map is getting too big, so I will break it into ''draw_border'', ''draw_player'' and ''draw_mobs''. Then we will add 'draw_world', 'draw_stats' and 'draw_msg'. These individual functions will all be called by draw_map. This makes it easier to co-ordinate things. Secondly, let's move all these functions into a file called draw.asm, and the main file can be changed from robots.asm to rogueima.asm. You can keep an old copy of robot.asm if you want, but we aren't going to use it anymore.
What is a "mob" you ask? A mob is a "mobile object". In the game, each tile is represented by a letter; monsters, as well as walls, doors, items and the player, will all be represented by characters. This means a moving object like a monster (or the player) is the same kind of thing as a wall or a sword; an object in the game.
So let's get all the drawing functions done. draw_map: CALL @draw_borders CALL @draw_world CALL @draw_mobs CALL @draw_player CALL @draw_stats CALL @draw_msgs RET draw_borders: ; Put the old draw_map code in here. RET draw_player: ; Put the old draw-player code in here. RET draw_mobs: ; put the old draw robot code in here. draw_stats: ; We will work on this next. RET draw_msgs: ; We will work on this after. RET draw_world: ; We will work on this last. RET Our plan of attack is to write draw stats, then msgs, then world. For each one, we will need to define some sort of variable to hold the data. For STATUS, we already have everything we need; name, strength, and health. We'll throw in "steps" and "score". You can add the steps and score variables yourself, or we will include them in the intermission program listing shortly. To write at a specific locatoin, we will need to use the set_cursor routine: ; ============================================================================ ; INT 0x10, AH=15h - Set Cursor Position ; Input: Y = row (0-based) ; X = column (0-based) ; Output: Clamps X and Y to valid range. ; Notes: Works with current video mode (reads VIDEO_ROWS, VIDEO_COLUMNS) ; ============================================================================ We will also use INT 0x05 IO_PUTNUM, to draw numbers up to 65,535: ; ---------------------------------------------------------------------------- ; INT 0x05, AH=$63: IO_PUTNUM - Output number to screen ; Input: B = number to output (16-bit, signed) ; AL = 0 unsigned, 1 signed (we will let the caller decide) ; Output: Number written to screen as ASCII digits ; ---------------------------------------------------------------------------- draw_stats: ; Set cursor LDA $1500 LDX #61 LDY #2 INT 0x10 LDELM @str_name ; Draw 'Name: ' LDA $1800 ; write string INT 0x10 ; Draw player's name. ; This will draw right after the 'Name: ' above. ; That's why we added spaces to the strings (below). LDELM @player_name LDA $1800 ; write string INT 0x10 ;;;;;;;;;;;;;;;;;;;; Strength LDA $1500 ; set cursor LDX #61 LDY #4 INT 0x10 LDELM @str_str ; Draw 'Str: ' LDA $1800 ; write string INT 0x10 ; Draw player's strength score LDA $6300 ; print unsigned word (will print after string above). LDB [@player_str] INT 0x10 ;;;;;;;;;;;;;;;;;;;; Health LDA $1500 ; set cursor LDX #61 LDY #5 INT 0x10 LDELM @str_hp ; Draw 'Health: ' LDA $1800 ; Write string INT 0x10 LDA $6300 ; Write number (unsigned) LDB [@player_hp] INT 0x05 ;;;;;;;;;;;;;;;;;;;; Score LDA $1500 ; Set cursor LDX #61 LDY #7 INT 0x10 LDELM @str_score ; Draw 'Score: ' LDA $1800 ; write string INT 0x10 LDA $6300 ; print number LDB [@player_score] INT 0x05 ;;;;;;;;;;;;;;;;;;;; Time LDA $1500 ; set cursor LDX #61 LDY #8 INT 0x10 LDELM @str_time ; Draw 'Time: ' LDA $1800 ; write string INT 0x10 LDA $6300 ; print number LDB [@game_time] INT 0x05 RET str_name: .bytes "Name: ", 0 str_str: .bytes "STR: ", 0 str_hp: .bytes "HP: ", 0 str_time: .bytes "T: ",0 str_score: .bytes "Score: ", 0 Finally, there's one more little change we need to make. Remember that the old ''robot'' game was 40x25? Let's head over to the ''get_input:'' function and just change where the player is able to move. move_right: LDAL [@PX] INC AL CMP AL, #59 JNC @mr_ok ; AL < 59 (since we're in mode 6 for this game). LDAL #58 mr_ok: STAL [@PX] RET ==== MVP 1: Base game Now that we have status drawing nicely, let's remove the draw_robot function. We will keep the stub, but we won't draw the robot or check for collision. You can also remove move_robots and change it to move_mobs (add a stub; you can make all these changes to your own copy). What's left is a simple game where you can move the player around. But nothing interesting happens, so let's increment game time every tick; add a CALL @inc_game_time (or similar) to the game loop (at the end): inc_game_time: LDA [@game_time] INC A STA [@game_time] RET We'll also add a way for the player to quit out, since we changed the game loop. Change quit_game to this: quit_game: ; CLS LDAH $10 INT $10 ; Print "QUIT GAME" LDBLX @msg_quit LDAH $66 INT $05 POP ELM ; destroy return address of CALL from main loop RET ; exit program. We now have, essentially, a 'game'; the longer you play it, the higher your score. The top score is 65,535 -- after that, it rolls over. * Source code so far: [[Rogueima I MVP 1]] == Variables To proceed from this point we need to talk about variables and data structres. First we will talk a bit about NVAR, and why it's no good here. Then we will introduce structs and INSQUE. NVAR stands for named variable system. It is the system BASIC uses to store variables. It stores data starting at $FFFF and grows downwards towards program space. This lets you efficiently use the space in bank 0 to separate code and data. Note that there are no memory protections here; it's up to you not to accidentally over-write your code or data. To do this, check PATB_VARTOP ($01EEE4; 3 bytes). This points to the top of variable storage. If you want to reserve 4k of memory for variables for example, don't add more variables if PATB_VARTOP falls below $F000 ($F000 to $FFFF is 4k). For our purposes we will ignore this, since our code and data is not expected to be more than a few kilobytes. We should have plenty of room to spare.
**So,** Using NVAR is very cumbersome in assembly because it requires the use of strings to set and get variable names and data.
=== Should you use NVAR? In a previous version of this tutorial, I wrote several examples that outlined how to do parallel arrays in assembly language using NVAR. It took hundreds of lines of code and was very clunky. After writing the section on structs and INSQUE, I realized that the code had no value, not even for educational purposes. NVAR is just not useful for assembly programming because it only understands strings, even when storing integers. To be honest I don't even know if the old code worked. It was getting huge and was nowhere near finished. Ultimately, there's nothing "wrong" with NVAR, but it's designed to be a part of Stellar BASIC, and not to be used as a general purpose variable storage library. For assembly, we should focus on structs and INSQUE instead. == Game Objects using Structures Structures are C's ''struct'' done by hand. They are fixed (or variable length) data //structures,// hence the name. Structures are easy because they are mechanical and quick to program, but they are also difficult because you need to re-write the CRUD (create-read-update-destroy) functions for every structure. This is where programming in a language like C makes things faster. Yet, a little work never hurt anyone, and at runtime, nothing is faster than a fixed width assembly struct. Let's dive right in. First, we define the structure itself, which is the location of the data in the record. What we'll do here is just define every kind of thing we need to know about an object: ; Object structure .equ OBJ_ID 0 ; 2 bytes, max 64k objects .equ OBJ_TYPE 2 ; 2 bytes; ex. 0 = nothing, 1 = wall, 2=gold, etc. .equ OBJ_X 4 ; 2 bytes; x position on map .equ OBJ_Y 6 ; 2 bytes; x position on map .equ OBJ_GLYPH 8 ; 1 byte; what it looks like (char) .equ OBJ_VIS 9 ; 1 byte; what it looks like on the map (char) .equ OBJ_NAME 10 ; 16 bytes + 1 zero (16 max len str) .equ OBJ_DATA1 27 ; 2 bytes (data 1, ex. gold value) .equ OBJ_DATA2 29 ; 2 bytes (data 1, ex. gold value) .equ OBJ_LEN 31 ; record length for an OBJ. This gives us everything we need to know to access an object once we find it. While we're at it, we can temporarily use the same structure for monster data. Health and Strength can be kept in DATA1 and level/other can be kept in DATA2. Since we are combining objects and monsters in one structure (this is where the term ''MOB'' or ''mobile object'' comes from), we can calculate how many monsters/objects we will be able to have in the game. If we use Bank 3 for this data, we have 64k, enough to hold over 2,000 objects at 31 bytes per record. === CRUD For any struct we need Create, Read, Update and Destroy accessor functions. However, we will not design functions like that just yet; instead we will look at an example first, to understand the concept. === Example Create ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Create object ;; ;; Returns the ID of a free object in A. ;; A = 0 if no free space. ;; ;; .equ OBJ_STRUCT_BASE $F000 ; memory location of data structure .equ OBJ_STRUCT_END $FFFF ; End of valid memory create_obj: PUSH X PUSH FLD ; Variable set-up: 3 register variables : X: stores current record ID ; ELM: Address of current record : FLD: stores last valid memory address for an object LDX #0 ; object ID counter LDELM @OBJ_STRUCT_BASE ; object data base LDFLD @OBJ_STRUCT_END SUB FLD, @OBJ_LEN create_obj_loop: ADD ELM, @OBJ_TYPE ; Point to object type LDA [ELM] ; Get object type SUB ELM, @OBJ_TYPE ; Return to start-of-struct CMP A, #0 ; Is it an unused object (TYPE=0)? JZ @create_obj_found ; Yes, we found an empty object. INC X ; Point ID to next record ADD ELM, @OBJ_LEN ; Point to next record's memory address CMP ELM, FLD ; Do we have space to write a record here? JC @create_obj_not_found ; Yes. That means we didn't find a free ID. JMP @create_obj_loop ; Try again with new ELM/ID no. create_obj_found: ; Return free ID in A ; ELM already points to the free object address. MOV A, X POP FLD POP X RET create_obj_not_found: MOV A, #0 ; 0 = no free space POP FLD POP X RET This code is very simple. It walks the struct and if it finds an object with no type, it returns it's ID in A and the address of the free struct memory in ELM. Here is an example of what the initialization subroutine might look like: ;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Init object structure ;; input/output: none ;; ;; Clears all objects and ensures their ID is set. ;; ;;;;;;;;;;;;;;;;;;;;;;;;;; init_struct_obj: PUSH A PUSH X PUSH ELM PUSH FLD ; Variable set-up: 4 register variables ; ELM: Address of Record ; A: stores default type (will be 0) : X: stores current record ID : FLD: stores last valid memory address for an object LDELM @OBJ_STRUCT_BASE ; object data base LDA #0 ; type will be set to 0 LDX #0 ; object ID counter LDFLD @OBJ_STRUCT_END ; load end of memory SUB FLD, @OBJ_LEN ; reserve space for at least one object init_struct_obj_loop: ADD ELM, @OBJ_ID ; point to ID STX [ELM] ; Store ID SUB ELM, @OBJ_ID ; return to start-of-record ADD ELM, @OBJ_TYPE ; point to TYPE STA [ELM] ; Store TYPE SUB ELM, @OBJ_TYPE ; return to start-of-record INC X ; Point ID to next record ADD ELM, @OBJ_LEN ; Point to next record's memory address ; check if next record is valid CMP ELM, FLD ; Does this record have enough space (is it valid?) JNC @init_struct_obj_loop ; Yes, set it up (ELM >= FLD = set carry). ; finished initializing. POP FLD POP ELM POP X POP A RET === NVAR or Struct? Each approach has benefits and drawbacks. The NVAR system is useful for dynamic allocation. This can be a huge win. It's an obvious choice for string-based hash tables. But since you have to write your own accessors to use NVAR anyways, you might as well use structs. A custom struct will always be smaller and faster than using NVAR. But there is another big reason to use structs; it allows you to make lists with INSQUE. == INSQUE, REMQUE, SCANQUE INSQUE deals with linked lists. It is a simple enough concept; each node has a forward link and a backward link, and the head is a data-less sentinel node at the start of variable memory. As it turns out, this makes programming data structures very easy, so we will use this final method for our game. To start, we must create a HEAD node. This is just a six byte sentinel node that points to itself: LDBLX $A000 LDELM $A000 STBLX [ELM] ; Set FLINK to point to itself. ADD BLX, 3 STBLX [ELM] ; Set BLINK to point to itself. $A000: 00 0A 00 00 0A 00 Setting FLINK (forward link) and BLINK (backward link) to the same address creates the HEAD node. The data at +6 is considered user data. When used with a struct this adds six bytes to the total struct. We now define our struct like this: ; Object structure (offset includes 6 byte INSQUE header) .equ OBJ_FLINK 0 ; forward-link (managed by INSQUE) .equ OBJ_BLINK 3 ; back-link (managed by INSQUE) .equ OBJ_ID 6 ; 2 bytes, max 64k objects .equ OBJ_TYPE 8 ; 2 bytes; ex. 0 = nothing, 1 = wall, 2=gold, etc. .equ OBJ_X 10 ; 2 bytes; x position on map .equ OBJ_Y 12 ; 2 bytes; x position on map .equ OBJ_GLYPH 14 ; 1 byte; what it looks like (char) .equ OBJ_VIS 15 ; 1 byte; what it looks like on the map (char) .equ OBJ_NAME 16 ; 16 bytes + 1 zero (16 max len str) .equ OBJ_DATA1 33 ; 2 bytes (data 1, ex. gold value) .equ OBJ_DATA2 35 ; 2 bytes (data 2, reserved) .equ OBJS_BASE $030000 ; objects data starts here .equ OBJS_END $03FFFF ; use all of bank 3 (over 1,700 objects at 37 bytes/obj.) .equ OBJ_LEN 37 ; record length for an object record including 6 byte header The original 31 byte STRUCT is now 37 bytes in total length. What do we get for using INSQUE instead of a flat structure? There are many useful advantages. === Adding and Deleting nodes You can easily add a new node anywhere in the list without moving memory by calling INSQUE to insert a new node after an existing node. Inserting after the head node inserts at the front of the list: .equ HEAD_NODE $A000 .equ HEAD_BLINK $A003 ; Insert as first node (after head node). INSQUE ELM, @HEAD_NODE ; Insert after head node ; Append as last node (after last node) LDBLX @HEAD_NODE LDBLX [BLX+3] ; get HEAD.BLINK (last node) INSQUE ELM, BLX ; Insert after last node (append to list) INSQUE modifies the HEAD node and any other nodes it needs to in order to link all the nodes together in a circular list. These records now form a linked list which you can traverse by looking at the first six bytes of each node. Byte 0: 24 bit pointer to next node Byte 3: 24 bit pointer to previous node Byte 6: data To delete such a node, REMQUE ELM ; Where ELM points to any node. This will delete the node at ELM by unlinking it from the list; the node at it's FLINK will be connected to the node at it's BLINK. The data isn't changed, but it will no longer appear in the list's chain of links. ==== Sorting a list You can easily sort a list by unlinking an 'B' object in ELM and adding it after an 'A' object with: REMQUE ELM ; unlink node A (at ELM) from the list. INSQUE ELM, FLD ; insert node A (at ELM) after B (at FLD). === Memory Management INSQUE offers several advantages in terms of memory management. In any struct-based system system you can traverse the list of objects and check for some field that indicates the object is unused. Such as id=0 or an ''alive'' flag. But with INSQUE you can use SCANQUE to find this space very quickly. LDA $0000 ; Object ID, for example SCANQUE ELM, A, @OBJ_ID ; Find first $0000 at index OBJ_ID Pointing to the start of the list is just ''LDELM [@OBJS_BASE]''. This loads the address of the first object on the list. Initialization is just like a flat struct; point to the field you need to configure to show an object is empty and set that field for every object. Then finding free space is a matter of looking for an object which is not in use. init_objects: ; Write blank HEAD node at @OBJS_BASE (this clears the whole list) LDELM @OBJS_BASE ; ELM = node ptr LDFLD @OBJS_BASE ; FLD = FLINK/BLINK ptr STELM [FLD] ; write FLINK and advance to BLINK ADD ELM, 3 STELM [FLD] ; write BLINK (and leave FLD pointed at HEAD.BLINK) ADD ELM, #6 ; advance past head to first data node space. LDGLK @OBJS_END SUB GLK, @OBJ_LEN INC GLK ; GLK now equals first invalid address init_obj_loop: ; 1. test if there's enough memory for another object CMP GLK, ELM ; Will we overflow memory if we initialize an object here? JNC @init_obj_done ; Yes, so exit (ELM >= GLK == CF). ; 2. initialize this object space LDI @OBJ_ID STB [ELM + I] ; Write id = 0 for this record INSQUE ELM, [FLD] ; FLD is a ptr to HEAD.BLINK; Append to list ; 3. loop ADD ELM, @OBJ_LEN ; point to next object JMP @init_obj_loop ; loop init_obj_done: LDELM [@OBJS_BASE] ; Load HEAD.FLINK (Point to first object on list) RET This is just 17 lines of code; vastly smaller than the NVAR init, get and set code -- and those functions didn't have any memory overflow checks. If your records are not of equal length (or if you just prefer to unlink them when you remove them) you can perform garbage collection by copying each record to a new space, using INSQUE to create new records. Then either change the OBJ_BASE pointer to the new object data, or copy the objects back to the original location. == Representing Items and Monsters in the Game World Adding objects and monsters to the map becomes easy. We simply traverse the list of all objects and display anything with an X and a Y coordinate. Here is the new create_gold function using structs and INSQUE: create_gold: ; Find a free space in the object list LDA #0 LDELM [@OBJS_BASE] ; start scanning at HEAD.FLINK (i.e. the first object) LDI @OBJ_ID SCANQUE ELM, A, I JNZ @create_gold_oom ; Since we're using a sentinel node, if the sentinel is a match it means there was no valid node. CMP ELM, @OBJS_BASE JZ @create_gold_oom LDA [@obj_ids] LDI @OBJ_ID STA [ELM+I] INC A STA [@obj_ids] LDA #1 ; obj type 1 = gold LDI @OBJ_TYPE STA [ELM+I] ;; Now that we'e saved type, lets get its X, Y and amount data. LDAH $00 ; 16 bit random number INT 0x13 ; math servics MOD B, #100 INC B ; amount is now 1 to 100. LDI @OBJ_DATA1 STB [ELM+I] LDAH $00 ; 16 bit random number INT 0x13 MOD B, #58 INC B ; random number 1 to 58 LDI @OBJ_X STB [ELM+I] LDAH $00 ; 16 bit random number INT 0x13 MOD B, #23 INC B ; random number 1-23 LDI @OBJ_Y STB [ELM+I] ; Add GLYPH and VIS. LDAL #'$' LDI @OBJ_GLYPH STAL [ELM+I] LDI @OBJ_VIS STAL [ELM+I] CLC ; no error RET ; return create_gold_oom: SEC ; set carry on error RET Putting an object on the map becomes a simple matter of adding it to the list of objects. === Testing item generation Let's test our new item and object system by adding ''create_gold'' as a command (bound to ''g''). We'll also add arrow key movement. gi_check_keys: CMP AL, #'H' JZ @move_left CMP AL, #'J' JZ @move_down CMP AL, #'K' JZ @move_up CMP AL, #'L' JZ @move_right CMP AL, #128 JZ @move_left CMP AL, #129 JZ @move_down CMP AL, #130 JZ @move_up CMP AL, #131 JZ @move_right CMP AL, #'G' JZ @create_gold CMP AL, #'Q' JZ @quit_game RET In addition, add a call in ''draw_map:'' ... CALL @draw_world CALL @draw_items ; <-- add this CALL @draw_player ... Now, we need to create the draw_items function. All it does is loop through every item, find it's X, Y and glyph, and draw it: ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Draw all the items that are placed on the map. ;; draw_items: LDELM @OBJS_BASE ; Start at first object draw_items_loop: LDELM [ELM] ; Traverse to next object CMP ELM, @OBJS_BASE JZ @draw_items_done ; End loop if we returned to HEAD node. LDI @OBJ_ID LDA [ELM+I] ; get object ID. JZ @draw_items_loop ; Skip non-initialized/empty objects LDI @OBJ_TYPE LDA [ELM+I] CMP A, #1 JNZ @draw_items_loop ; not amn item. skip ; Does this item have an X and Y position? LDI @OBJ_X LDX [ELM+I] JZ @draw_items_loop LDI @OBJ_Y LDY [ELM+I] JZ @draw_items_loop ; Get the VIS (visible glyph) of the object. LDI @OBJ_VIS LDBL [ELM+I] LDAH $11 ; write char BL at XL, YL INT $10 JMP @draw_items_loop ; Next! draw_items_done: RET Finally, add a call to ''init_items'' at the end of ''init_game'' in rogueima.sda: init_game: ... LDA #0 STA [@game_time] STA [@player_score] CALL @init_objects ; Initialize the game objects list. RET With all this, pressing g will create piles of gold which appear on the map! === Picking up gold It makes sense that the player is able to pick up gold. First, let's detect if there is any object on the square the player is standing on. If there is, we can print a small message. First let's add a pick up command to the command list: gi_check_keys: .... CMP AL, #'G' JZ @create_gold CMP AL, #'Q' JZ @quit_game CMP AL, @',' // pick things up using comma JZ @pick_up_items RET Next, let's add gold that the player picks up to his inventory and remove such gold from the objects list. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Pick up items ;; Picks up the first pile of gold where the player is standing. ;; pick_up_items: LDELM @OBJS_BASE ; Start at first object pick_up_items_loop: LDELM [ELM] ; Traverse to next object CMP ELM, @OBJS_BASE JZ @pick_up_items_done ; End loop if we returned to HEAD node. LDI @OBJ_ID LDA [ELM+I] ; get object ID. JZ @pick_up_items_loop ; Skip non-initialized/empty objects ; Does this item match the player's location? LDI @OBJ_X LDX [ELM+I] ; object x LDIL [@PX] ; player x CMP IL, XL JNZ @pick_up_items_loop ; No match, keep looking. LDI @OBJ_Y LDY [ELM+I] ; objecy y LDJL [@PY] ; player y CMP JL, YL JNZ @pick_up_items_loop ; No match, keep looking. ; Oh, there's an item here! LDI @OBJ_TYPE LDA [ELM+I] CMP A, #1 ; is it gold? JNZ @pick_up_items_loop ; No, keep looking. ; How much gold is it? LDI @OBJ_DATA1 LDA [ELM+I] LDB [@player_score] ADD B, A ; Add one point for every gold. STB [@player_score] ; Save score. LDA #0 LDI @OBJ_ID ; Remove the gold object from the game STA [ELM+I] ; by zeroing it's ID (thats all!) pick_up_items_done: RET Now you can have fun running around picking up treasure. We have already come so far! === Designing an 8 bit rogue It's worth taking a moment to pause here and look, really look, at the data struct-ures we have so far and think carefully about how we want to proceed. Ideally what we **want** is an easy pointer from where we stand to a list of items. That's easy to do in a flat structure or with an insque. Let's say we want to have a map as data. Each square takes up space. But at 37 bytes a record, a 256x256 world map will take more space than we have total RAM. How did games like Ultima 4, 5 and 6 have such large world maps and so many objects? There are a few answers. First, be realistic. For Ultima 6, it was intended for computers with more memory. If you stored the Ultima 6 world map as an array of bytes, it would be 1 megabyte in size. The dungeon areas were just as large. Ultima 4 and 5 had a 256x256 world map -- just large enough to be byte-addressable. But since it couldn't fit in memory how did they do it? The answer is that they didn't load it into memory all at once. They kept it on disk or on the cartridge (or CD) and read only the parts they needed to display. A roguelike game has around as many tiles and data, more or less, or at least must be able to support a similar amount of data. It is worth noting however that ROGUE, HACK as they were written are much more suited to a 16 bit environment. They didn't really exist in the 8 bit world; although there were some notable ports, no full version of the game ever made it to systems like the C64. Telengard was a decent try, but the microcomputer era was much more well known for games like Sword of Fargoal and the Temple of Apshai. That being said, an 8-bit style ROGUE is possible, with three main constraints: * 1. The map must be byte-addressable. * 2. The map data and all objects must fit in one bank to be plausable. * 3. 80 column mode was not common on some microcomputers; we use it, but in monochrome. Four, even; We should take care not to allow more data than would feasably be able to be stored on a floppy disk to exist in the system as a total (about 160k). The game should be designed to fit in that space as a total package. If we want more data, we need to come up with an excuse, such as we're bit-packing the map into 4 bits (and only allow 32 different tile types). Or we're using compression. At any rate, we will not need to worry about larger data sets until we do a world map (which I will probably skip until Rogueima II). == Rendering the Maps Now let's work on displaying a map. To display a map, we will need three important things: # A source of truth for the map # A projection of that map onto the play area # Game logic that provides map interaction The first and most important thing we need is the map itself. Here is our first world map: map1_id: .bytes 1 map1_name: .bytes "START ", 0 ; 8 characters and zero terminated map1_up: .bytes 5, 5 map1_down: .bytes 22, 15 map1_dim: .bytes 80, 40 map1_data: .bytes "################################################################################" ; 0 .bytes "# # #" ; 1 .bytes "# # #" ; 2 .bytes "# # #" ; 3 .bytes "# # #" ; 4 .bytes "# @ + #" ; 5 .bytes "# # #" ; 6 .bytes "# # ######### #" ; 7 .bytes "# # # # #" ; 8 .bytes "# # # + #" ; 9 .bytes "#####+##### # # #" ; 10 .bytes "# # # #" ; 11 .bytes "# ######### #" ; 12 .bytes "# #" ; 13 .bytes "# #" ; 14 .bytes "# > #" ; 15 .bytes "# #" ; 16 .bytes "# #" ; 17 .bytes "# #" ; 18 .bytes "# #" ; 19 .bytes "# #" ; 20 .bytes "# #" ; 21 .bytes "# #" ; 22 .bytes "# #" ; 23 .bytes "# #" ; 24 .bytes "# #" ; 25 .bytes "# #" ; 26 .bytes "# #" ; 27 .bytes "# #" ; 28 .bytes "# #" ; 29 .bytes "# #" ; 30 .bytes "# #######+#############+#############+#######" ; 31 .bytes "# # # # #" ; 32 .bytes "# # # # #" ; 33 .bytes "# # # # #" ; 34 .bytes "# # # # #" ; 35 .bytes "# # # # #" ; 36 .bytes "# # # # #" ; 37 .bytes "# # # # #" ; 38 .bytes "################################################################################" ; 39 This map was constructed to be very simple and allow us to test: * Having a map in the first place (it works!) * Reading and displaying the map * Map interaction (Opening and closing doors, walls blocking movement and vision) (Note, the @ and > are added for visual reference. They will be ignored by the game). === Projecting the Map To project the map we need to calculate the area of the map around the player that will fit in the play area. We will draw that box in the play area. The first step is to gather all the data. We have ''map1_dim'', now we need ''disp_dim'' (display dimensions). disp_dim: .bytes #58, #23 This represents the play area which is 58x23 wide. Second, we need to re-bound the player's movement to the map, and not to the play area. Change the movement code to use ''@map1_dim:'' move_left: LDAL [@PX] JZ @ml_done DEC AL ; was not zero; can move left. STAL [@PX] ml_done: RET move_down: LDAL [@PY] INC AL LDB [@map1_dim] DEC BH CMP AL, BH ; should be set to MAP_HEIGHT JNC @md_ok ; AL < map_height (not <=!!) MOV AL, BH md_ok: STAL [@PY] RET move_up: LDAL [@PY] JZ @mu_done DEC AL STAL [@PY] mu_done: RET move_right: LDAL [@PX] INC AL LDB [@map1_dim] DEC BL CMP AL, BL ; Should be set to MAP WIDTH. JNC @mr_ok ; AL < map width MOV AL, BL mr_ok: STAL [@PX] RET This will bound the player's PX and PY to a co-ordinate which also exists on the map. Next, we need to //project// the area around the player to the play area. === Java method from NetWhack This algorithm is something I came up with many years ago for the Java game NetWhack. I'll show you the render function in Java first, then show the assembly version. // Pre: lvl and pc are not null. public void redraw_screen(Level lvl, Player pc) { // Take the position of the PC on the map. // Draw a screen-sized box around the PC with the PC in the middle. int from_x = pc.xpos - vs.screen.maxcols / 2; int from_y = pc.ypos + 2 - vs.screen.maxrows / 2; // Add 2 for message line. int to_x = pc.xpos + vs.screen.maxcols / 2; int to_y = pc.ypos - 3 + vs.screen.maxrows / 2; // Subtract 3 for stats line. // These two if statements are a kind of kludge. // the problem with largemaps could be fixed another way. if ((to_x - from_x) >= vs.screen.maxcols) { to_x--; from_x++; } if ((to_y - from_y) >= vs.screen.maxrows) { to_y--; from_y++; } // Move the box so that the left edge of the box is within map bounds. while (from_x < 0) { from_x++; if (to_x < lvl.xmax) { to_x++; // dont increase to_x beyond map boundaries. } } // Move the box so that the right edge of the box is within map bounds. while (to_x >= lvl.xmax) { to_x--; if (from_x > 0) { from_x--; // dont decrease to_x beyond map boundaries. } } // Move the box so that the top edge of the box is within map bounds. while (from_y < 0) { from_y++; if (to_y < lvl.ymax) { to_y++; // dont increase to_y beyond map boundaries. } } // Move the box so that the bottom edge of the box is within map bounds. while (to_y >= lvl.ymax) { to_y--; if (from_y > 0) { from_y--; // dont decrease to_y beyond map boundaries. } } // At this point we have a box which is guranteed to be equal to or // smaller than the size of the screen, and also guranteed to be within // map bounds. // So, we should center this box on the drawable area of the screen. int at_x, at_y; if ((to_x - from_x) < vs.screen.maxcols) { at_x = (vs.screen.maxcols - Math.abs(to_x - from_x)) / 2; } else { at_x = 0; } if ((to_y - from_y) < (vs.screen.maxrows - 3 - 3)) { at_y = (vs.screen.maxrows - Math.abs(to_y - from_y)) / 2; } else { at_y = 2; } // double check screen heights... //if ((to_y-from_y)>=(vs.maxcols-3)) to_y--; bufrenderlevel(lvl, at_x, at_y, from_x, from_y, to_x, to_y); } The above Java code uses an //iterative// approach. The function we will use below is a //closed calculation//. They execute on the same basic idea and should produce identical results. I am undecided which algorithm I like better. ==== draw_world draw_world: LDT [@disp_dim] ; TL = play_width, TH = play_height LDK [@map1_dim] ; KL = map_width, KH = map_height LDX #0 LDY #0 LDXL [@PX] LDYL [@PY] MOV I, X MOV J, Y ; Clamp I to [0, KL - TL] MOV AL, TL DEC AL SHR AL, #1 ; AL = (TL-1)/2 = centered offset CMP IL, AL JC @dw_i_lo_ok MOV IL, AL dw_i_lo_ok: SUB IL, AL MOV AL, KL SUB AL, TL ; AL = max_I_start CMP IL, AL JNC @dw_i_hi_ok MOV IL, AL dw_i_hi_ok: ; Clamp J to [0, KH - TH] MOV AL, TH DEC AL SHR AL, #1 CMP JL, AL JC @dw_j_lo_ok MOV JL, AL dw_j_lo_ok: SUB JL, AL MOV AL, KH SUB AL, TH CMP JL, AL JNC @dw_j_hi_ok MOV JL, AL dw_j_hi_ok: ; put player screen position into C, D LDAL [@PX] SUB AL, IL INC AL LDAH #0 MOV C, A LDAL [@PY] SUB AL, JL INC AL LDAH #0 MOV D, A ; render loop LDX #1 LDY #1 MOV G, I dw_x_loop: LDELM @map1_data ADD ELM, I MOV Z, J MUL Z, KL ADD ELM, Z LDBL [ELM] LDAH $11 INT 0x10 INC I INC X CMP TL, XL ; continue while TL >= X JC @dw_x_loop LDX #1 MOV I, G INC J INC Y CMP TH, YL JC @dw_x_loop ; same trick on the outer ; Draw player on top LDAH $11 LDBL #'@' MOV X, C MOV Y, D INT 0x10 RET ==== draw_map Finally let's change draw_map: to this: draw_map: LDAH $51 ; VSTOP INT 0x18 CALL @draw_borders CALL @draw_world ;CALL @draw_items ;CALL @draw_mobs ;CALL @draw_player CALL @draw_stats CALL @draw_msgs LDAH $50 ; VSTART INT 0x18 RET The draw_item, draw_player and draw_monster funtions need to be commented out until we calculate their location on the map. This adjustment number will be saved during draw_world and used to calculate the projection offset for items, monsters and so on. == Projecting Items We also need to change items so they draw based on the map location. The solution is to save the camera origin during draw_world so we can use it later to draw anything else. Create the variables: cam_origin_x: .bytes 0,0 cam_origin_y: .bytes 0,0 Save them in draw_world after clamping I and J: dw_j_lo_ok: SUB JL, AL MOV AL, KH SUB AL, TH CMP JL, AL JNC @dw_j_hi_ok MOV JL, AL dw_j_hi_ok: STIL [@cam_origin_x] ; Save computed camera origin STJL [@cam_origin_y] Now, we can use these coordinates to rework the draw_items function to make items appear on the map in their proper location: ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Draw all the items that are placed on the map. ;; draw_items: LDD [@cam_origin] ; DL = I_start, DH = J_start LDT [@disp_dim] ; TL, TH for upper-bound additions LDELM @OBJS_BASE draw_items_loop: LDELM [ELM] CMP ELM, @OBJS_BASE JZ @draw_items_done LDI @OBJ_ID LDA [ELM+I] JZ @draw_items_loop LDI @OBJ_X LDX [ELM+I] JZ @draw_items_loop LDI @OBJ_Y LDY [ELM+I] JZ @draw_items_loop ; is (XL, YL) inside the viewport? CMP XL, DL JNC @draw_items_loop ; XL < I_start (off-screen left) MOV AL, DL ADD AL, TL CMP XL, AL JC @draw_items_loop ; XL >= I_start + TL (off-screen right) CMP YL, DH JNC @draw_items_loop ; YL < J_start (off-screen up) MOV AL, DH ADD AL, TH CMP YL, AL JC @draw_items_loop ; YL >= J_start + TH (off-screen down) ; xlate map to screen SUB XL, DL ADD XL, #1 ; play_x_offset SUB YL, DH ADD YL, #1 ; play_y_offset ; draw LDI @OBJ_VIS LDBL [ELM+I] LDAH $11 INT $10 JMP @draw_items_loop draw_items_done: RET Now, finally, you will notice that when the player steps over an item the item draws over the player. This is because the player is being drawn in draw_world! Refactor the player drawing code back into draw_player and uncomment the CALL @draw_player line in the game loop. You can now remove the section "player screen locations into C and D" from draw_world: draw_player: LDBL #'@' LDXL [@PX] LDYL [@PY] LDIL [@cam_origin_x] LDJL [@cam_origin_y] SUB XL, IL ; XL = PX - I_start ADD XL, #1 ; play area x offset (same as draw_world) SUB YL, JL ; YL = PY - J_start ADD YL, #1 ; play area y offset (it's because of the screen border). LDAH $11 INT $10 RET == Map Interaction Next let's add a little map interaction. Players cannot step onto walls (''='') or closed doors (''+''). Furthermore, we can let players can open or close doors with the O key (which then allows them to choose a direction). We will need a few functions; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; get_glyph(X, Y) -> AL ;; ;; Returns the map glyph at column X, row Y. ;; Caller is responsible for X, Y being within map bounds. ;; ;; Inputs: X = column, Y = row ;; Output: AL = glyph byte (e.g. '#', '.', '+', ' ') ;; Clobbers: ELM, K, Z ;; get_glyph: PUSH ELM PUSH K PUSH Z LDK [@map1_dim] ; KL = map_width MOV Z, Y ; Z = Y MUL Z, KL ; Z = Y * map_width ADD Z, X ; Z = Y * map_width + X LDELM @map1_data ADD ELM, Z ; ELM -> map[Y][X] LDAL [ELM] ; AL = glyph POP Z POP K POP ELM RET ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; is_walkable(X, Y) ;; ;; Sets CARRY if this tile is walkable. ;; ;; Non-walkable tiles: = and + ;; is_walkable: PUSHA CALL @get_glyph ; AL now has glyph. CMP AL, #'#' JZ @is_walkable_no CMP AL, #'+' JZ @is_walkable_no ;; default: yes. is_walkable_yes: POPA SEC RET is_walkable_no: POPA CLC RET These two functions will now let us check to see if the tile is walkable. Inside the player movement section, we can now check to see if the player has landed on a walkable tile. If no, we don't save the player's move: move_left: LDAL [@PX] JZ @ml_done DEC AL ; AL = new PX LDX #0 LDY #0 MOV XL, AL ; XL = new PX (survives CALL) LDYL [@PY] CALL @is_walkable JNC @ml_done STXL [@PX] ml_done: RET move_right: LDAL [@PX] INC AL ; AL = new PX LDB [@map1_dim] ; BL = map_width CMP AL, BL JC @mr_done ; AL >= map_width — out of bounds, abort LDX #0 LDY #0 MOV XL, AL ; XL = new PX LDYL [@PY] CALL @is_walkable JNC @mr_done STXL [@PX] mr_done: RET move_up: LDAL [@PY] JZ @mu_done DEC AL ; AL = new PY LDX #0 LDY #0 LDXL [@PX] MOV YL, AL ; YL = new PY (survives CALL) CALL @is_walkable JNC @mu_done STYL [@PY] mu_done: RET move_down: LDAL [@PY] INC AL ; AL = new PY LDB [@map1_dim] ; BH = map_height CMP AL, BH JC @md_done ; AL >= map_height — out of bounds, abort LDX #0 LDY #0 LDXL [@PX] MOV YL, AL ; YL = new PY CALL @is_walkable JNC @md_done STYL [@PY] md_done: RET Now you will notice that the move function rejects moves that would put the player on an 'unwalkable' tile. Things are getting spicy! == is_walkable in object creation Make sure you modify the create_gold function to use is_walkable, so we don't accidentally spawn gold on a wall or closed door. Change the random x,y position from the play screen to a map-aware x,y finder: LDK [@map_dim] create_gold_xy: LDAH $00 ; 16 bit random number INT 0x13 MOD B, KL MOV X, B LDAH $00 ; 16 bit random number INT 0x13 MOD B, KH MOV Y, B CALL @is_walkable JNC @create_gold_xy ; store final x,y location LDI @OBJ_X STX [ELM+I] LDI @OBJ_Y STY [ELM+I] ; Add GLYPH and VIS. LDAL #'$' LDI @OBJ_GLYPH STAL [ELM+I] LDI @OBJ_VIS STAL [ELM+I] == Opening doors When the player presses O, we have a secondary key input that directionally searches for a door symbol (+ or -). If it finds one, it changes the symbol. This is the method by which doors can be opened and closed. First change ''gi_check_keys:'' to add the new command: .... CMP AL, #'O' JZ @open_door CMP AL, #'G' JZ @create_gold CMP AL, #'Q' JZ @quit_game CMP AL, #',' // pick things up using comma JZ @pick_up_items RET Next, we need the ''open_door:'' function: ==== open_door ;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; open_door() ;; This replicates the gi_get_keys pattern ;; but is used to directionally change a door's status. ;; open_door: ; Ask player for key LDAH $02 ; Blocking GETKEY -> AL INT $10 ;; Add the player's key as entropy. LDAH $02 INT 0x13 ; Uppercase: if AL >= 'a' and AL < 'z'+1, subtract 32 CMP AL, #97 JNC @od_check_keys ; AL < 'a', skip CMP AL, #123 JC @od_check_keys ; AL >= '{', skip SUB AL, #32 od_check_keys: ; Check direction given CMP AL, #'H' JZ @open_door_left CMP AL, #'J' JZ @open_door_down CMP AL, #'K' JZ @open_door_up CMP AL, #'L' JZ @open_door_right CMP AL, #128 JZ @open_door_left CMP AL, #129 JZ @open_door_down CMP AL, #130 JZ @open_door_up CMP AL, #131 JZ @open_door_right ; Bad direction -- ignore. RET open_door_up: LDI #0 LDJ #0 LDK #0 LDL #1 JMP @open_door_go open_door_down: LDI #0 LDJ #1 LDK #0 LDL #0 JMP @open_door_go open_door_left: LDI #0 LDJ #0 LDK #1 LDL #0 JMP @open_door_go open_door_right: LDI #1 LDJ #0 LDK #0 LDL #0 JMP @open_door_go open_door_go: LDX #0 LDY #0 LDXL [@PX] LDYL [@PY] ADD XL, IL ADD YL, JL SUB XL, KL SUB YL, LL CALL @get_glyph CMP AL, #'+' JZ @open_door_open_it CMP AL, #'-' JZ @open_door_close_it ;; bad command? ignore. RET open_door_open_it: LDAL #'-' CALL @put_glyph RET open_door_close_it: LDAL #'+' CALL @put_glyph RET Note that this function relies on ''put_glyph:'' -- this function is exactly the same as get_glyph except you change the LDAL to a STAL. ==== put_glyph ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; put_glyph(X, Y, AL) ;; ;; Places the glyph AL at x, y ;; Caller is responsible for X, Y being within map bounds. ;; ;; Inputs: X = column, Y = row, AL = chr (ex. 'A') ;; put_glyph: PUSH ELM PUSH K PUSH Z LDK [@map1_dim] ; KL = map_width MOV Z, Y ; Z = Y MUL Z, KL ; Z = Y * map_width ADD Z, X ; Z = Y * map_width + X LDELM @map1_data ADD ELM, Z ; ELM -> map[Y][X] STAL [ELM] ; AL = glyph POP Z POP K POP ELM RET In the same way we interact with items and the map itself, we can now programatically interact with anything on the map. Our game is almost complete! There's just one more thing we "should" add, a message window. == The Message Window A message window is an important part of any game that uses text to communicate with the player. In our case, we need to display messages in a small area of the screen and not have them spill over. We can do this by creating a message buffer and manually printing into that buffer as we go. First let's create the data structures we will need; a message buffer and all the data to work with it: msg_dim_x: .bytes #19, #0 msg_dim_y: .bytes #13, #0 ; width and height msg_home_x: .bytes #60, #0 msg_home_y: .bytes #11, #0 ; X and Y where to start drawing msg_cursor_x: .bytes #0, #0 ; X internal cursor location (start: left side) msg_cursor_y: .bytes #12, #0 ; Y internal cursor location (start: lower row) msg_data: ; 13 rows of 19 bytes (must match msg_dim) .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Let's target movement and the opening of doors as a test of the system: ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; String data ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; str_open_where: .bytes "> Open where?", 13, 10, 0 str_NORTH: .bytes "NORTH", 0 str_EAST: .bytes "EAST", 0 str_SOUTH: .bytes "SOUTH", 0 str_WEST: .bytes "WEST", 0 str_OPEN_DOOR: .bytes " OPEN DOOR ", 0 str_crlf: .bytes #13, #10, #0 === Message window functions Writing to the message window is very straightforward. You have a print function, and the window is printed during the draw cycle. Replace ''CALL @draw_msgs'' with ''CALL @msg_display'' in ''draw_map:' in ''draw.sda''. That will print the messages; now we just have to get from //print_message// to //msg_display//! ==== print_msg: Our first stop is the intended use case: print_msg. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; print_msg(ELM) ;; ;; ELM points to a string. This function ;; simplifies a "puts" type function by ;; calling the "putc" type function. print_msg: PUSH A ; We over-write AL so save register A. PUSH ELM ; Save pointer to string print_msg_loop: LDAL [ELM] ; Load char and advance ELM. INC ELM JZ @print_msg_done ; If we hit a zero, (end of string), then exit. CALL @msg_putchar ; Writes char al at position in msg_cursor JMP @print_msg_loop print_msg_done: POP ELM ; restore original string pointer POP A RET This function delegates actually printing to a msg_putchar function. This is a fair divvy of work. ==== msg_putchar: The putchar function simply calculates the correct position in the char-buffer and writes the ASCII code. That would be the end of it, but we need to handle special characters. Here we handle CR and LF; you could add other special characters here too. The divvy here is that this is a dispatcher; if it doesn't print the character, it delegates any special work to a helper function. These are: * msg_putchar_advance: responsible for advancing the cursor (and all that comes with that) * msg_do_cr: a simple function, just moves x. * msg_do_lf: a more in-depth function, linked to msg_scroll: ;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; msg_putchar(AL) ;; looks at msg_cursor, msg_dim, etc. and writes the character. ;; msg_putchar: ;; We are called with character in AL. ;; Find the current cursor position: LDX [@msg_cursor_x] LDY [@msg_cursor_y] ; Is AL a special character? CMP AL, #10 ; line feed JZ @msg_putchar_lf CMP AL, #13 ; carriage return JZ @msg_putchar_cr mpc_check_done: ; Not a special character. Print it. LDFLD @msg_data ADD FLD, X LDI [@msg_dim_x] MUL Y, I ; multiply Y by row length to get ptr ADD FLD, Y STAL [FLD] ; Store this character in the buffer. ; Advance the cursor properly. CALL @msg_putchar_advance msg_putchar_done: RET msg_putchar_lf: CALL @msg_do_lf JMP @msg_putchar_done msg_putchar_cr: CALL @msg_do_cr JMP @msg_putchar_done ==== msg_putchar_advance: This is a cursor advance function but it is (mis)-named //putchar_advance// because it is intended only to be called by ''msg_putchar''. The divvy here is that if CR/LF needs to be performed, it calls those functions as helper functions. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Advance the cursor properly. ;; This is a big responsibility! ;; We may need to linefeed the msg window. ;; msg_putchar_advance: PUSHA LDX [@msg_cursor_x] LDY [@msg_cursor_y] INC X ; Attempt to move cursor. ;; Check if we moved past the X-width LDI [@msg_dim_x] CMP X, I ; Since X is 0 based, if it is equal to I it's out of bounds. JNZ @mpc_done ; X is fine, so we're done. ; We've moved past the right side, do a CR/LF. CALL @msg_do_cr ; Carriage return CALL @msg_do_lf ; Do a linefeed, will scroll if necessary. mpc_done: STX [@msg_cursor_x] STY [@msg_cursor_y] POPA RET ==== msg_do_cr: and msg_do_lf: These are the carriage_return and line_feed functions. Essentially, we just increment the cursor location; but in the case of line_feed we may need to scroll the screen. ;;;;;;;;;;;;;;;;; ;; msg_do_cr ;; this doesn't affect the buffer in any way, just the cursor. ;; msg_do_cr: LDX #0 STX [@msg_cursor_x] RET ;;;;;;;;;;;;;; ;; msg_do_lf ;; Just lf. ;; Adds a line only if we need to. msg_do_lf: PUSH J INC Y LDJ [@msg_dim_y] CMP Y, J JNC @msg_do_lf_done CALL @msg_scroll ; manually scroll window. msg_do_lf_done: STY [@msg_cursor_y] POP J RET ==== msg_scroll: If the message buffer needs to be fed a new line, this is the function you would call. ;;;;;;;;;;;;;;;;;;;;;;;;; ;; msg_scroll ;; Called by msg_do_lf. ;; You probably don't want to call this by itself. ;; Note that it modifies Y, and the caller may depend on this. msg_scroll: PUSH ELM PUSH FLD CMP Y, 0 ; if Y is zero, JZ @msg_scroll_nodec ; don't dec Y. DEC Y ; We keep the cursor at the same position it was at in the buffer. STY [@msg_cursor_y] msg_scroll_nodec: LDI [@msg_dim_x] ; get row size LDJ [@msg_dim_y] ; get height LDELM @msg_data ; get start of msg area LDFLD @msg_data ; copy start into FLD ADD ELM, I ; skip one row in ELM (start) DEC J ; don't copy last row (# of bytes) MUL J, I ; find total bytes to move except last row msg_scroll_loop: LDAL [ELM] STAL [FLD] INC ELM INC FLD DEC J CMP J, #0 JNZ @msg_scroll_loop LDFLD @msg_data LDI [@msg_dim_x] LDJ [@msg_dim_y] DEC J ; AL = last row index MUL J, I ; AL = byte offset of last row ADD FLD, J LDAL $20 ; space msg_scroll_clear: STAL [FLD] INC FLD DEC I JNZ @msg_scroll_clear ; write a blank line POP FLD POP ELM RET ; Buffer has been updated! ==== msg_display: This is the round trip now, we're done! This is the function that will copy the message buffer onto the screen. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; msg_display ;; Copies the message buffer to the screen at (msg_home_x, msg_home_y). ;; Treats zero bytes as spaces so the all-zeros init looks blank. ;; msg_display: PUSHA LDELM @msg_data ; ELM walks through the buffer LDY #0 LDYL [@msg_home_y] ; Y = current screen row LDJL [@msg_dim_y] ; JL = rows remaining mdpy_row: LDX #0 LDXL [@msg_home_x] ; X = current screen col (start of row) LDIL [@msg_dim_x] ; IL = cols remaining in this row mdpy_col: LDBL [ELM] ; BL = char, advance ELM INC ELM JNZ @mdpy_print LDBL #' ' ; map 0 -> space for the renderer mdpy_print: LDAH $11 INT $10 ; write char BL at XL, YL INC X DEC IL JNZ @mdpy_col INC Y DEC JL JNZ @mdpy_row POPA RET == Rogueima I: Final Thoughts There are a lot of things we could add now. * Random level generation * The ability to go deeper into the dungeon * Loading and saving levels * A win condition (such as getting a certain sword from deep in the dungeon) * Monsters * Combat * Ranged weapons Each of these things presents an interesting challenge to the programmer, but, these are not strictly game design concerns that depend on SDA-8516 Assembly Language, they are mechanical algorithmic processes that build almost entirely on what you already know. The one interesting thing would be random levels; I will include some level generation functions below taken from Java NetWhack; you can feel free to use them, if you can translate them into assembly! === Improvements and optimizations Looking at the direction the code took, I would rather have some way of accessing an inventory (a list) on a per-tile basis to determine things about that tile. However, no matter how you try and arrange the data, you will never be able to have an index into a large map; it just requires too much memory. A head node for a world map (256x256) would require 6 banks of memory. That just isn't going to work on a machine with less memory! The first solution is to only store the visible tiles in memory. Since the play window is 58x23, what if we only stored the current screen (and a small buffer zone) in memory? What if all we did was store a glyph and two bytes that pointed to an in-bank HEAD node? If we used 64*28, that's 5,376 bytes. On top of this, 100 objects and 100 monsters would equal 7,400 bytes. That seems about right for such a game. If you can get the data to load and save and can spare the 12k RAM, you could have maps of unusual size. There's also the zelda technique, where you just load a new map chunk and scrol into it when you move past the edge; this is a fun technique too, and allows us to deal solely with the play area. Another technique is shrinking the play area. Compared to rogue, etc. this is the Bard's Tale and Ultima 4 solution; The Bard's tale had a small play window in the upper left of the screen; Ultima 4 had an 11x11 play window. 11x11! Even if you stored the 33x33 tiles around the player with HEAD pointers, that's just 3,267 bytes! Storing maps in 11x11 chunks is very interesting. When you think back to how Ultima 4 and 5 worked, isn't it true that monsters more than 2 screens away would tend to disappear? That is a clue to the method they used to cache the world map. Additionally, most dungeon combat and dungeon rooms were one-screen. This saved a lot of memory, and greatly contributed to the game's look and feel. You didn't notice it so much, since the towns and world map were much larger. A third solution is splitting the difference. if the top 16 tiles on the map account for ~95% of the data, you can save space by bit packing those tiles into half a byte, and then including a list of special objects (ex. a bridge, a moongate, a town/abbey) in a separate list of objects to be drawn on the map. This allows you to store a huge world map (ex. 256*256) in just 32k. You could even get it down to 24k if you only had 8 main terrain types. How many terrain types did Ultima 4 use? Glancing at a //shapes// file there appeaer to be only 22-23 overworld tiles, possibly a few more; It would be easy to split some tiles off into a special list (like towns, bridges and docks, shrines, and such). In fact, it seems as if Ultima 4's world map was designed with this as a fallback: * Basic terrain (tiles 0–8): Deep Water, Medium Water, Shallow Water, Swamp, Grasslands, Scrubland/Brush, Forest, Hills, Mountains. * Special location/overlay tiles (commonly placed on the map): Dungeon Entrance (9), Town (10), Castle (11), Village (12), Ruins (29), Shrine (30), various moongates, bridges, Lord British’s castle pieces, etc. Having 16 basic tiles means you can bit pack a 256x256 map into 32k. Next, you can have a small list of extra overlay tiles. If you cut the map into 4x4 (16 chunks), each chunk would fit into 2k memory. Then an additional list of over 200 monsters and items could exist in this chunk, using //just// another 2k of memory. The object struct for this would be 10 bytes; 2 bytes for ID, 2 bytes for NEXT_ID (for containers), 2 bytes for X and Y in the chunk, 1 byte type, 3 bytes data. === Large, active worlds If you absolutely want to make a very large, active world (civilization clone?) think about what you must absolutely do with each thing. For example if you have to keep track of a crowd of 20,000 people, and they have four behavior states (ex. chopping wood, mining gold, farming, bulding) then each person's behavior strategy can be stored in 2 bits. This means you need 40,000 bits, or 5k. If you only have 16 types of object in your game (or 8) consider bit packing for that as well. Bit packing is cheap; computing it (decoding it) is also cheap, in general. How far can you push the machine to make it play the kind of games you want to make? ==== Moving the monsters It would be very easy to move the monsters. Simply, just calculate the difference between each monster's position and the player's position and have the monster try to move towards the player. That's all. Combat occurrs when a monster or player tries to occupy the other's space. Monsters will naturally attack players by trying to move onto their square. It's a simple function, just check if the monster is moving onto the player's co-ordinates, or if there is a monster where the player is moving. A function like ''tile_hasmon'' would be very useful here; if the tile has a monster it returns a pointer to the monster (ex. in FLD). Then you can write a combat routine. ==== Levels In the same way you would save data for larger maps you can save and load entire levels. To do this you would want to rewrite parts of the game to use map_ map1_ and then make map_ a pointer to an area where you load and save maps. Or do this with map1 and then make a map2 that you can load from disk. Either way, adding a load and save map function is vital for traversing up and down dungeon levels. A save game function is not far away from a save level/save map function. === Go forth and code great games! //Good artists copy, great artists steal. This means if you want to be good, write a game similar to an existing popular game. But if you want to be great, you must take the ideas you get from that game and make them your own ideas -- creatively! Part of the challenge of writing a game is being creative and coming up with your own stuff. But to do this you need inspiration. Play the games of old; hack, rogue, adventure, zork, The Bard's Tale, Ultima -- The Legend of Zelda -- and you will understand what you must do!// == Rogueima I MVP-4 Source Code Here is the complete source code for the tutorial (including the extensions in Appendix I). To compile this code, concatenate the files in any order (rogueima.sda must be first). Then INCOPY the file and type: as allfiles.sda rogueima This will assemble the game. Then you can play it by typing: SYS 49152 I may return to soup it up a little with some easy monster AI and a quest ("Thou must kill... a viper!") but this is really only intended as an instructional game to show you how to program in assembly. If you'd like to continue from here, you may wish to check out another game in the "Writing Games in Assembly Language" series: [[The Rogue's Tale I]]. Coming soon! * [[Rogueima I MVP-4]] == Appendix I Extending the Game As a bonus lesson, i'll show you how easy it is to extend the game. First, if you don't know how to do something, just think about it. For a couple of days, I knew that the key to adding monsters was in the create_gold function; because monsters are ''mobs'' in programmer lingo; that is, ''mobile objects''. And I knew the key to extending the game to include a TALK command would then require a version of the ''open_door'' command. So let's get started. === Spawning Monsters By this time you know how to add a new command, so add two new commands: m will call ''spawn_monster:'' and ''t'' will call ''do_talk:''. Writing spawn_monster is easy. 1. Copy the create_gold function into a file called mon.sda and rename it to spawn_monster. 2. Change the object type (in spawn_monster) to be #2 (monster). Why 2? Gold is 1, monster is 2. Everything has to be different so we know what kind of object it is. 3. Now, rip out all the random number generation and put it in a function called ''random_monster:''. We will now change the random stuff slightly so it is for a monster and not a pile of gold. Now, draw_items will also draw monsters automatically! To make the visuals right (monsters always walk on top of items) add a check like this to draw_items: And we will make a draw_mobs function too (that only draws item type #2). This draw_mobs will be called after items, and before player: draw_mobs: LDD [@cam_origin_x] ; DL = I_start, DH = J_start LDT [@disp_dim] ; TL = play_width, TH = play_height LDELM @OBJS_BASE draw_mobs_loop: LDELM [ELM] CMP ELM, @OBJS_BASE JZ @draw_mobs_done LDI @OBJ_ID LDA [ELM+I] JZ @draw_mobs_loop ; uninitialized slot LDI @OBJ_TYPE LDA [ELM+I] CMP A, #2 ; OBJ_TYPE_MONSTER JNZ @draw_mobs_loop ; only draw monsters LDI @OBJ_X LDX [ELM+I] LDI @OBJ_Y LDY [ELM+I] ; can see the monster? CMP XL, DL JNC @draw_mobs_loop MOV AL, DL ADD AL, TL CMP XL, AL JC @draw_mobs_loop CMP YL, DH JNC @draw_mobs_loop MOV AL, DH ADD AL, TH CMP YL, AL JC @draw_mobs_loop ; map to screen SUB XL, DL ADD XL, #1 SUB YL, DH ADD YL, #1 ; draw LDI @OBJ_VIS LDBL [ELM+I] LDAH $11 INT $10 JMP @draw_mobs_loop draw_mobs_done: RET === Extending and refactoring Notice that draw_mobs is essentially a cut and paste of draw_items. This suggests that it is possible to refactor some of the code into helper functions. Same thing with create_items vs. spawn_monsters. Same idea. Next up we will do the talk command. The same idea will apply; talk is essentially a copy of the open command. === The 'Talk' command Did you add the talk command to the key dispatcher yet? gi_check_keys: CMP AL, #'H' JZ @move_left CMP AL, #'J' JZ @move_down CMP AL, #'K' JZ @move_up CMP AL, #'L' JZ @move_right CMP AL, #128 JZ @move_left CMP AL, #129 JZ @move_down CMP AL, #130 JZ @move_up CMP AL, #131 JZ @move_right CMP AL, #'M' JZ @spawn_monster CMP AL, #'T' JZ @do_talk CMP AL, #'O' JZ @open_door CMP AL, #'G' JZ @create_gold CMP AL, #'Q' JZ @quit_game CMP AL, #',' // pick things up using comma JZ @pick_up_items RET Now, here's the whole talk.asm file. We'll examine this a bit more quickly since it's just a copy of open doors (essentially). It works the same way -- ask the player for a direction and then print a message about what happens when you talk to any particular thing. ; Talk.sda ; handle talking. ; Right now you can only talk to a monster, unfortunately. ; Or an item? ;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; do_talk() ;; This started as a copy of open_door ;; do_talk: ; Show "Talk where?" LDELM @str_talk_where CALL @print_msg CALL @msg_display ; Ask player for key LDAH $02 ; Blocking GETKEY -> AL INT $10 ;; Add the player's key as entropy. LDAH $02 INT 0x13 ; Uppercase: if AL >= 'a' and AL < 'z'+1, subtract 32 CMP AL, #97 JNC @talk_check_keys ; AL < 'a', skip CMP AL, #123 JC @talk_check_keys ; AL >= '{', skip SUB AL, #32 talk_check_keys: ; Check direction given CMP AL, #'H' JZ @talk_west CMP AL, #'J' JZ @talk_south CMP AL, #'K' JZ @talk_north CMP AL, #'L' JZ @talk_east CMP AL, #128 JZ @talk_west CMP AL, #129 JZ @talk_south CMP AL, #130 JZ @talk_north CMP AL, #131 JZ @talk_east ; Bad direction -- ignore. RET talk_north: ; Show "talk north" LDELM @str_TALK CALL @print_msg LDELM @str_NORTH CALL @print_msg LDELM @str_crlf CALL @print_msg LDI #0 LDJ #0 LDK #0 LDL #1 JMP @talk_go talk_south: LDELM @str_TALK CALL @print_msg LDELM @str_SOUTH CALL @print_msg LDELM @str_crlf CALL @print_msg LDI #0 LDJ #1 LDK #0 LDL #0 JMP @talk_go talk_west: LDELM @str_TALK CALL @print_msg LDELM @str_WEST CALL @print_msg LDELM @str_crlf CALL @print_msg LDI #0 LDJ #0 LDK #1 LDL #0 JMP @talk_go talk_east: LDELM @str_TALK CALL @print_msg LDELM @str_EAST CALL @print_msg LDELM @str_crlf CALL @print_msg LDI #1 LDJ #0 LDK #0 LDL #0 JMP @talk_go talk_go: LDX #0 LDY #0 LDXL [@PX] LDYL [@PY] ADD XL, IL ADD YL, JL SUB XL, KL SUB YL, LL CALL @get_glyph CMP AL, #' ' JZ @talk_air CMP AL, #'#' JZ @talk_wall CMP AL, #'+' JZ @talk_cdoor CMP AL, #'-' JZ @talk_odoor CMP AL, #'r' JZ @talk_rat CMP AL, #'s' JZ @talk_snake CMP AL, #'x' JZ @talk_spider ;; bad command? ignore. RET talk_air: LDELM @str_ytt CALL @print_msg LDELM @str_talk_air CALL @print_msg RET talk_wall: LDELM @str_ytt CALL @print_msg LDELM @str_talk_wall CALL @print_msg RET talk_cdoor: LDELM @str_ytt CALL @print_msg LDELM @str_talk_cdoor CALL @print_msg RET talk_odoor: LDELM @str_ytt CALL @print_msg LDELM @str_talk_odoor CALL @print_msg RET talk_rat: LDELM @str_ytt CALL @print_msg LDELM @str_talk_rat CALL @print_msg RET talk_snake: LDELM @str_ytt CALL @print_msg LDELM @str_talk_snake CALL @print_msg RET talk_spider: LDELM @str_ytt CALL @print_msg LDELM @str_talk_spider CALL @print_msg RET ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; talk strings str_talk_where: .bytes " > Talk where? ", 13, 10, 0 str_TALK: .bytes " TALK ", 0 str_ytt: .bytes " You talk to ", 0 str_talk_air: .bytes "the ", 13, 10, " air. But no-one", 13, 10, " is there!", 13, 10, 0 str_talk_wall: .bytes "the ", 13, 10, " wall. It's not ", 13, 10, " listening.", 13, 10, 0 str_talk_cdoor: .bytes "the ", 13, 10, " closed door. ", 13, 10, 32, 34,"Open sesame!", 34, 13, 10, 0 str_talk_odoor: .bytes "the ", 13, 10, " open door. Nothing happens.... YET!", 13, 10, 0 str_talk_rat: .bytes "the ", 13, 10, " rat. It squeaks", 13, 10, " in anger!", 13, 10, 0 str_talk_snake: .bytes "the ", 13, 10, " snake. It hisses", 13, 10, " dangerously!", 13, 10, 0 str_talk_spider: .bytes "the ", 13, 10, " spider. It moves", 13, 10, " closer!", 13, 10, 0 === Fixing a bug Notice how when you try to talk to a monster, it doesn't work? That's because get_glyph only looks at the map. Let's add a check for any item or monster. get_glyph: PUSH ELM PUSH FLD PUSH K PUSH Z PUSH I PUSH B ; map tile glyph LDK [@map1_dim] MOV Z, Y MUL Z, KL ADD Z, X LDELM @map1_data ADD ELM, Z LDAL [ELM] ; AL = map glyph (default return) ; Override if any object sits at (X, Y) LDFLD @OBJS_BASE get_glyph_obj_loop: LDFLD [FLD] CMP FLD, @OBJS_BASE JZ @get_glyph_done ; wrapped to head, no match, keep map glyph LDI @OBJ_ID LDB [FLD+I] JZ @get_glyph_obj_loop ; uninitialized slot LDI @OBJ_X LDBL [FLD+I] CMP BL, XL JNZ @get_glyph_obj_loop LDI @OBJ_Y LDBL [FLD+I] CMP BL, YL JNZ @get_glyph_obj_loop ; Match will override AL with the object's VIS, then exit loop LDI @OBJ_VIS LDAL [FLD+I] get_glyph_done: POP B POP I POP Z POP K POP FLD POP ELM RET == Appendix II: Monster Combat Just one more thing! Here's how to make monsters move around and attack you. First we need to know if a square has a monster or not. This means, when we try to move onto a square, we test if that square has a monster. If it does, we attack that monster. ==== has_mon This function tells us if there is a monster on the tile. ;;;;;;;;;;;;;;;;;; ;; has_mon(x,y) ;; ;; Returns CARRY SET if monster. ;; Returns monster pointer in FLD. ;; has_mon: PUSH I PUSH B LDFLD @OBJS_BASE has_mon_loop: LDFLD [FLD] CMP FLD, @OBJS_BASE JZ @has_mon_no LDI @OBJ_ID LDB [FLD+I] JZ @has_mon_loop ; uninitialized slot LDI @OBJ_TYPE LDB [FLD+I] CMP B, #2 ; OBJ_TYPE_MONSTER JNZ @has_mon_loop LDI @OBJ_X LDBL [FLD+I] CMP BL, XL JNZ @has_mon_loop LDI @OBJ_Y LDBL [FLD+I] CMP BL, YL JNZ @has_mon_loop ; Match returns pointer in FLD, CF=1 SEC JMP @has_mon_done has_mon_no: CLC ; no monster; FLD unchanged. has_mon_done: POP B POP I RET ==== move_mon This is where we attempt to move the monsters. Don't forget to move them //before// you draw them in the game loop: draw_map: LDAH $51 ; VSTOP INT 0x18 CALL @draw_borders CALL @draw_world CALL @draw_items CALL @move_mon CALL @draw_mobs CALL @draw_player CALL @draw_stats CALL @draw_msgs CALL @msg_display LDAH $50 ; VSTART INT 0x18 RET Add this in, and add ''CALL @move_mon'' before ''CALL @draw_mon'' in ''draw_map:''. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; move_mon ;; ;; Attempt to move every live monster ;; towards the player ;; move_mon: LDELM @OBJS_BASE move_mon_loop: LDELM [ELM] CMP ELM, @OBJS_BASE JZ @move_mon_done ; live monsters only LDI @OBJ_ID LDA [ELM+I] JZ @move_mon_loop LDI @OBJ_TYPE LDA [ELM+I] CMP A, #2 ; OBJ_TYPE_MONSTER JNZ @move_mon_loop LDI @OBJ_X LDXL [ELM+I] LDI @OBJ_Y LDYL [ELM+I] ; sign(PX - mx) to CL LDAL [@PX] CMP AL, XL JZ @mm_dx_zero JC @mm_dx_pos ; PX > mx LDCL #$FF ; PX < mx, step left JMP @mm_dx_done mm_dx_pos: LDCL #$01 JMP @mm_dx_done mm_dx_zero: LDCL #$00 mm_dx_done: ; sign(PY - my) into DL LDAL [@PY] CMP AL, YL JZ @mm_dy_zero JC @mm_dy_pos LDDL #$FF JMP @mm_dy_done mm_dy_pos: LDDL #$01 JMP @mm_dy_done mm_dy_zero: LDDL #$00 mm_dy_done: ; Pick axis CMP CL, #0 JNZ @mm_dx_nz CMP DL, #0 JZ @move_mon_loop ; both zero, monster sits on player, skip JMP @mm_use_y mm_dx_nz: CMP DL, #0 JZ @mm_use_x ; Both non-zero (coin flip) LDAH $00 INT 0x13 MOD B, #2 CMP B, #0 JZ @mm_use_x ; fall through to mm_use_y mm_use_y: ADD YL, DL ; YL = my + sign(dy) JMP @mm_check mm_use_x: ADD XL, CL ; XL = mx + sign(dx) mm_check: ; Don't step on the player LDAL [@PX] CMP AL, XL JNZ @mm_check_obj LDAL [@PY] CMP AL, YL JZ @move_mon_loop mm_check_obj: PUSH ELM CALL @has_mon JC @mm_pop_skip ; another monster occupies target CALL @is_walkable JNC @mm_pop_skip ; wall or other non-walkable ; commit the move POP ELM LDI @OBJ_X STXL [ELM+I] LDI @OBJ_Y STYL [ELM+I] JMP @move_mon_loop mm_pop_skip: POP ELM JMP @move_mon_loop move_mon_done: RET === Combat When a monster attempts to move onto a player, this is a combat situation. When a player attempts to move onto a monster, this is also a combat situation. Ideally we want to use the same code, but this will never work out in practice unless we add the player as an object in the item list (so it has data like a monster). This is actually one way of running the game, but for now we will have a special case to set up player vs monster or monster vs player. What we will do is set up a general data structure and have the code for player/monster at the start and end. That will be good enough for now. Let's change mon_move to call the combat function: ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; move_mon ;; ;; Attempt to move every live monster ;; towards the player ;; move_mon: LDELM @OBJS_BASE move_mon_loop: LDELM [ELM] CMP ELM, @OBJS_BASE JZ @move_mon_done ; live monsters only LDI @OBJ_ID LDA [ELM+I] JZ @move_mon_loop LDI @OBJ_TYPE LDA [ELM+I] CMP A, #2 ; OBJ_TYPE_MONSTER JNZ @move_mon_loop LDI @OBJ_X LDXL [ELM+I] LDI @OBJ_Y LDYL [ELM+I] ; sign(PX - mx) to CL LDAL [@PX] CMP AL, XL JZ @mm_dx_zero JC @mm_dx_pos ; PX > mx LDCL #$FF ; PX < mx, step left JMP @mm_dx_done mm_dx_pos: LDCL #$01 JMP @mm_dx_done mm_dx_zero: LDCL #$00 mm_dx_done: LDAL [@PY] CMP AL, YL JZ @mm_dy_zero JC @mm_dy_pos LDDL #$FF JMP @mm_dy_done mm_dy_pos: LDDL #$01 JMP @mm_dy_done mm_dy_zero: LDDL #$00 mm_dy_done: ; Pick axis CMP CL, #0 JNZ @mm_dx_nz CMP DL, #0 JZ @move_mon_loop ; both zero, monster sits on player, skip JMP @mm_use_y mm_dx_nz: CMP DL, #0 JZ @mm_use_x ; Both non-zero; coin flip LDAH $00 INT 0x13 MOD B, #2 CMP B, #0 JZ @mm_use_x ; fall through to mm_use_y mm_use_y: ADD YL, DL ; YL = my + sign(dy) JMP @mm_check mm_use_x: ADD XL, CL ; XL = mx + sign(dx) mm_check: LDAL [@PX] CMP AL, XL JNZ @mm_check_obj LDAL [@PY] CMP AL, YL JNZ @mm_check_obj ; Target tile IS the player, so attack instead of move LDFLD @player_obj ; ELM attacks FLD CALL @do_combat JMP @move_mon_loop ; monster's turn is spent attacking mm_check_obj: PUSH ELM CALL @has_mon JC @mm_pop_skip ; another monster occupies target CALL @is_walkable JNC @mm_pop_skip ; wall or other non-walkable ; commit the move POP ELM SED LDI @OBJ_X STXL [ELM+I] LDI @OBJ_Y STYL [ELM+I] CLD JMP @move_mon_loop mm_pop_skip: POP ELM JMP @move_mon_loop move_mon_done: RET And now, the combat function. This is just an example; every attack does 1 damage to the defender. You could expand on this later. === combat.sda player_obj: ; 40 bytes, about sizeof(OBJ) .bytes 0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0 .bytes 0,0,0,0,0,0,0,0,0,0 str_you: .bytes "You ", 0 str_the: .bytes "The ", 0 str_attack: .bytes "attack for 1 damage!", 13, 10, 0 ; player verb str_attacks: .bytes "attacks for 1 damage!", 13, 10, 0 ; monster verb str_rat: .bytes "rat ", 0 str_snake: .bytes "snake ", 0 str_spider: .bytes "spider ", 0 str_unknown: .bytes "thing ", 0 str_dies: .bytes "dies!", 13, 10, 0 str_you_die: .bytes "You die!", 13, 10, 0 copy_player_to_obj: PUSH ELM PUSH A PUSH I LDELM @player_obj LDI @OBJ_DATA1 LDA [@player_hp] STA [ELM+I] LDI @OBJ_DATA2 LDA [@player_str] STA [ELM+I] POP I POP A POP ELM RET copy_obj_to_player: PUSH ELM PUSH A PUSH I LDELM @player_obj LDI @OBJ_DATA1 LDA [ELM+I] STA [@player_hp] LDI @OBJ_DATA2 LDA [ELM+I] STA [@player_str] POP I POP A POP ELM RET print_mon_name: PUSH ELM PUSH A PUSH I LDI @OBJ_VIS LDAL [ELM+I] CMP AL, #'r' JZ @pmn_rat CMP AL, #'s' JZ @pmn_snake CMP AL, #'x' JZ @pmn_spider LDELM @str_unknown JMP @pmn_emit pmn_rat: LDELM @str_rat JMP @pmn_emit pmn_snake: LDELM @str_snake JMP @pmn_emit pmn_spider: LDELM @str_spider pmn_emit: CALL @print_msg POP I POP A POP ELM RET ;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Call with: ;; ELM is the attacker ;; FLD is the defender ;; Note: If player is attacking or defending, ;; pass @player_obj in ELM or FLD as appropriate. ;; If the player is not involved, syncing doesn't hurt. ;; do_combat: ; sync CALL @copy_player_to_obj combat_damage: ; part 2: damage LDI @OBJ_DATA1 LDA [FLD+I] JZ @combat_msg ; defender already 0 (defensive) DEC AL STAL [FLD+I] ; HP -= 1, stored (may be 0) combat_msg: CMP ELM, @player_obj JZ @combat_msg_player PUSH ELM LDELM @str_the CALL @print_msg POP ELM CALL @print_mon_name ; ELM = attacker PUSH ELM LDELM @str_attacks CALL @print_msg POP ELM JMP @combat_check_dead combat_msg_player: PUSH ELM LDELM @str_you CALL @print_msg LDELM @str_attack CALL @print_msg POP ELM combat_check_dead: LDI @OBJ_DATA1 LDA [FLD+I] JNZ @combat_post CALL @kill_obj combat_post: CALL @copy_obj_to_player combat_done: RET kill_obj: ; Monster dies ; player will be handled in main loop PUSH ELM ; save caller's attacker MOV ELM, FLD ; ELM = victim for everything below PUSH ELM LDELM @str_the CALL @print_msg POP ELM CALL @print_mon_name ; reads OBJ_VIS via ELM = victim PUSH ELM LDELM @str_dies CALL @print_msg POP ELM LDI @OBJ_ID LDA #0 STA [ELM+I] ; free the slot POP ELM ; restore attacker RET kill_player: PUSH ELM LDELM @str_you_die CALL @print_msg POP ELM ; CLS LDAH $10 INT $10 ; Print "You died." LDBLX @msg_died LDAH $66 INT $05 POP ELM ; get rid of return address from CALL @draw_map game loop RET ; exit program. Finally, if the player is dead, we end the game. in ''draw_map:'' ... CALL @draw_stats CALL @draw_msgs CALL @msg_display LDAH $50 ; VSTART INT 0x18 LDA [@player_hp] CMP A, 0 JZ @kill_player RET == Appendix III: DungeonMaker Random dungeon maker used by NetWhack v0.7.2 I don't know where I got this from, but it is ancient code. It is likely older than I am. It's probably from the original hack or rogue games. Of course, it has been heavily modified, but the core is that old, ancient algorithm for blowing room bubbles and connecting them. I have removed some irreverent boilerplate to present a cleaner pseudocode-like version. // fill dungeon with solid rock. public void fill_with_rock() { amap = new char[xmax][ymax]; for (int ix = 0; ix < xmax; ix++) { for (int iy = 0; iy < ymax; iy++) { amap[ix][iy] = '#'; } } return; } public void bound_with_undiggable_walls() { // bound the map with undiggable walls for (int ix = 0; ix < xmax; ix++) { amap[ix][0] = 'X'; // X = undiggable wall amap[ix][ymax - 1] = 'X'; // X = undiggable wall } for (int iy = 0; iy < ymax; iy++) { amap[0][iy] = 'X'; amap[xmax - 1][iy] = 'X'; } return; } // returns true if area is filled with "diggable tiles". public boolean isWall(Coord j, Coord k) { for (int ix = j.x; ix <= k.x; ix++) { for (int iy = j.y; iy <= k.y; iy++) { if (amap[ix][iy] != '#') { return false; } } } return true; } public boolean isWall(Coord a) { return (amap[a.x][a.y] == '#'); } public boolean isWall(int x, int y) { return (amap[x][y] == '#'); } // Finds a wall we can build on. public Coord find_build_spot() { // We must find a spot for this feature. boolean success = false; int maxtries = (xmax * ymax) / 2; Coord a = new Coord(); while ((success == false) && (maxtries-- > 0)) { // 1. pick a random spot in the dungeon a.x = Dice.roll(1, xmax - 2); a.y = Dice.roll(1, ymax - 2); // 2. if it is a wall if (isWall(a)) { // 3. does it have *1* floor next to it? int floors = 0; int build_dir = 0; // opposite from the floor if (amap[a.x + 1][a.y] == ' ') { floors++; build_dir = WEST; } if (amap[a.x - 1][a.y] == ' ') { floors++; build_dir = EAST; } if (amap[a.x][a.y + 1] == ' ') { floors++; build_dir = NORTH; } if (amap[a.x][a.y - 1] == ' ') { floors++; build_dir = SOUTH; } if (floors == 1) { // 4. Yes, we found a wall we can attempt to build a feature on. return (a); } } // if it's a wall } // while loop // failure. return (new Coord(0, 0)); } // Determines the direction we should build in. // returns -1 on failure. public int find_build_dir(Coord a) { int floors = 0; int build_dir = 0; // opposite from the floor if (amap[a.x + 1][a.y] == ' ') { floors++; build_dir = WEST; } if (amap[a.x - 1][a.y] == ' ') { floors++; build_dir = EAST; } if (amap[a.x][a.y + 1] == ' ') { floors++; build_dir = NORTH; } if (amap[a.x][a.y - 1] == ' ') { floors++; build_dir = SOUTH; } if (floors == 1) { // 4. Yes, we found a wall we can attempt to build a feature on. return (build_dir); } // failure. return (-1); } // Adds a random feature to the dungeon. // Assumes there is already a feature in the dungeon. public boolean addfeature() { boolean success = false; int maxtries = (xmax * ymax) / 2; // Find a spot, a build direction, and try to add a feature. while ((success == false) && (maxtries-- > 0)) { Coord a = find_build_spot(); int build_dir = find_build_dir(a); success = addfeature(a, build_dir); } // while loop return success; } // builds a random feature at coord a, direction dir. public boolean addfeature(Coord a, int bd) { return addfeature(a.x, a.y, bd); } public boolean addfeature(int atx, int aty, int dir) { boolean success = false; int feature_type = Dice.roll(1, 50); switch (feature_type) { case 1: success = makeshop(atx, aty, dir); break; case 2: default: success = makeroom(atx, aty, dir); break; } // connect the features. if (success) { amap[atx][aty] = '+'; } return success; } public boolean makeroom(int xloc, int yloc, int dir) { // come up with a size for the room. int roomw = Dice.roll(2, 8); int roomh = Dice.roll(2, 6); Coord a = new Coord(); // set up the directional modifiers switch (dir) { case NORTH: a.x = xloc - roomw / 2; a.y = yloc - roomh - 1; break; case EAST: a.x = xloc + 1; a.y = yloc - roomh / 2; break; case SOUTH: a.x = xloc - roomw / 2; a.y = yloc + 1; break; case WEST: a.x = xloc - roomw - 1; a.y = yloc - roomh / 2; break; } Coord b = new Coord(a.x + roomw, a.y + roomh); check_coords(a, b); if (!isWall(a, b)) { return false; } // Mine the room. for (int ix = a.x; ix <= b.x; ix++) { for (int iy = a.y; iy <= b.y; iy++) { amap[ix][iy] = ' '; } } // we're done here. return true; } } The idea here is you create a room on a map of diggable tiles, surrounded by undiggable (end of map) tiles. Then you create a room, and mark the floor as connected. Then you create another room randomly somewhere else, and then draw a passage from the new room to the old room. then mark every floor space as 'connected'. Actually I think the code above is even simpler; it does not attempt to create corridors and always builds rooms beside each other. Ancient code indeed. For every room or feature you add, repeat this process. It is actually very easy to create a rogue or hack-style dungeon! Get creative with your features. NetHack has a lot of amazing features, Slash'Em has even more. Those are two very good sources to draw inspiration from. Or maybe you'd like to make your game more like ADOM? Or even ZZT? Include a pseudo-3d view like in Bard's Tale or Ultima 4? The future holds many exciting possibilities. I wish you luck in your game programming adventures!