top of page
Search
  • cedarcantab

Space Invaders in Phaser 3 (Part 7): Heads Up Display

Updated: Jan 18, 2022

Further to the previous blog, this is another simple and short one, where I document the way I created the "heads-up-display" (HUD). Frankly, the information displayed in the HUD is very limited (essentially, score and lives) so it would be easier to simply include all of this code in the maingame scene, but I have created a separate scene anyway.



There is little need to refer to the original code for the heads up display since writing text and displaying information in Phaser3 is so completely different from the way it was done in the original code. However, let's look at some of the more interesting bits.


Header information

Here is the code to display the various information at the top of the screen.


Top line of header: " SCORE<1> HI-SCORE SCORE<2> "

First $1C (28) is loaded into register C to tell it how many characters to print. Then the "position" (address in screen RAM) - $241E - is loaded into register HL. $241E is the byte positioned second from top, right at the left edge of the screen (in the rotated screen).


DrawScoreHead:
; Print score header " SCORE<1> HI-SCORE SCORE<2> "
191A: 0E 1C           LD      C,$1C               ; 28 bytes in message
191C: 21 1E 24        LD      HL,$241E            ; Screen coordinates
191F: 11 E4 1A        LD      DE,$1AE4            ; Score header message
1922: C3 F3 08        JP      PrintMessage        ; Print score header

1925: 21 F8 20        LD      HL,$20F8            ; Player 1 score descriptor
1928: C3 31 19        JP      DrawScore           ; Print score

192B: 21 FC 20        LD      HL,$20FC            ; Player 2 score descriptor
192E: C3 31 19        JP      DrawScore           ; Print score

The actual text to display at the top of the screen is stored at $1AE4, which appears to be a string of 28 characters including the space at the front and the space at the end. The Javascript equivalent coordinate is (0,8).


MessageScore:
; " SCORE<1> HI-SCORE SCORE<2> "
1AE4: 26 12 02 0E 11 04 24 1B 25 26 07 08   
1AF0: 3F 12 02 0E 11 04 26 12 02 0E 11 04   
1AFC: 24 1C 25 26    

Rendering the score

The code above in BLUE starts by loading HL with location for the relevant descriptor data. In the case of player 1 score, that is $20F8. The actual data (together with other bits & pieces) is reproduced below.



Then the below routine is called to read the necessary information into various registers, then Print4Digits is called to actually render the score as a 4 digit text.

DrawScore:
; Print score.
; HL = descriptor
1931: 5E              LD      E,(HL)              ; Get score LSB
1932: 23              INC     HL                  ; Next
1933: 56              LD      D,(HL)              ; Get score MSB
1934: 23              INC     HL                  ; Next
1935: 7E              LD      A,(HL)              ; Get coordinate LSB
1936: 23              INC     HL                  ; Next
1937: 66              LD      H,(HL)              ; Get coordiante MSB
1938: 6F              LD      L,A                 ; Set LSB
1939: C3 AD 09        JP      Print4Digits        ; Print 4 digits in DE

For our purposes, the important piece of information is the data stored for the location; which is $271C, which in the Javascript world is (24,24) in terms of the top-left hand corner of the text.


Displaying remaining players information

The code that is even more interesting is the below, which displays the number of players remaining. The code starts by loading register HL with the screen location; $2701 is byte sitting at the 25th pixels from the left and 2nd byte from the bottom (in the rotated screen).


DrawNumShips:
; Show ships remaining in hold for the player
19E6: 21 01 27        LD      HL,$2701            ; Screen coordinates
19E9: CA FA 19        JP      Z,$19FA             ; None in reserve ... skip display
; Draw line of ships
19EC: 11 60 1C        LD      DE,$1C60            ; Player sprite
19EF: 06 10           LD      B,$10               ; 16 rows
19F1: 4F              LD      C,A                 ; Hold count
19F2: CD 39 14        CALL    DrawSimpSprite      ; Display 1byte sprite to screen
19F5: 79              LD      A,C                 ; Restore remaining
19F6: 3D              DEC     A                   ; All done?
19F7: C2 EC 19        JP      NZ,$19EC            ; No ... keep going
; Clear remainder of line
19FA: 06 10           LD      B,$10               ; 16 rows
19FC: CD CB 14        CALL    ClearSmallSprite    ; Clear 1byte sprite at HL
19FF: 7C              LD      A,H                 ; Get Y coordinate
1A00: FE 35           CP      $35                 ; At edge?
1A02: C2 FA 19        JP      NZ,$19FA            ; No ... do all
1A05: C9              RET                         ; Out

At that location the player sprite is drawn "row" by "row" (don't forget, what looks like a vertical line of pixels of the player is actually a "row" of 8 bits in the actual screen of the SI, pre-rotation, by first loading register DE with the location of the sprite data ($1C60), loading register-B with the number of "rows" ($10=16) of the sprite, and then calling the following code.


Drawing the sprite routine

The code starts by loading register A with the content stored at address indicated by register-DE, ie the actual sprite. Then that is written to the address pointed at by register HL - ie screen location previously determined. Then register DE is incremented to point to the next row of the spite. $0020 (or 32) is loaded into register BC and that is added to register HL. Adding 32 to a screen address simply moves the location 1 pixel to the right (in the rotated screen). That process is repeated for each "row" of the image.

DrawSimpSprite:
; Display character to screen
; HL = screen coordinates
; DE = character data
; B = number of rows
1439: C5              PUSH    BC                  ; Preserve counter
143A: 1A              LD      A,(DE)              ; From character set ...
143B: 77              LD      (HL),A              ; ... to screen
143C: 13              INC     DE                  ; Next in character set
143D: 01 20 00        LD      BC,$0020            ; Next row ...
1440: 09              ADD     HL,BC               ; ... on screen
1441: C1              POP     BC                  ; Restore counter
1442: 05              DEC     B                   ; Decrement counter
1443: C2 39 14        JP      NZ,DrawSimpSprite   ; Do all
1446: C9              RET                         ; Out

This is looped for the remaining number of players. Once the required number of ships is draw, the rest of the screen to the left is erased, to the edge of the screen.


Fascinating stuff!


Javascript / Phaser 3 version

Suffice to say, writing the same thing in Javascript is much simpler.


class PlayScreen extends Phaser.Scene {
  constructor() {
     super('PlayScreen')
  }
 
   create() {
     this.scoreLabel = this.add.bitmapText(0, 8, 'TAITO', " SCORE<1> HI-SCORE SCORE<2>");
     this.p1Score = this.add.bitmapText(24, 24, 'TAITO', "0000");
     this.pLives = this.add.bitmapText(8, SCREEN_HEIGHT - 8, 'TAITO', "").setOrigin(0.5);
     const MAX_SHIP_TEXTURES = 4; // maximum remaining ship textures is 4
     this.shipTextures = this.add.group({
       key: 'player',
       quantity: MAX_SHIP_TEXTURES,
       visible: false
     });
     this.renderLives(3)

     this.scene.launch('MainGame');    
   } // end of create function

   renderLives(lives) { 
     this.shipTextures.getChildren().forEach((ship, i) => {     
       ship.setPosition(30+i*20, SCREEN_HEIGHT-8);
       ship.setVisible(i < lives-1 ? true : false)
     });   
     this.pLives.setText(lives)
   }
  
  renderScore(score) {
     this.p1Score.setText(Phaser.Utils.String.Pad(score, 4, '0', 1));
   }
 
  startNewGame() {
    this.scene.launch('MainGame');
  }
 } 

Methods to display information

The somewhat distinguishing features of this scene class are the methods to:

  1. render the score always as a 4 digit figure, and

  2. render lives, which is a combination of the total number of lives remaining, and the remaining lives 'in-standby' as images.


With these methods, it will make it a bit easier to illustrate what we do in the next post, which is hitting the aliens and incrementing the player score.


These methods are called from the (separate) main game scene as follows:


First, get reference to the HUD scene with the following line in the create function of main game scene.

 this.playScreen = this.scene.get('PlayScreen');

Then you can call the relevant method from the main game scene, like so:

this.playScreen.renderScore(this.score);

Structuring the main game scene

In the CODEPEN at the bottom of this post, I have also included the basic structure for the main Game scene.


The main game scene includes 6 states:

  • INIT - called at the beginning of a new game

  • STANDBY - initial state of a game, when the aliens are still being spawned. Player cannot move or shoot during this stage

  • PLAYING - this is the normal game state

  • LEVELCLEAR - when all the aliens have been wiped out

  • GAMEOVER - as the name suggests, when all player lives have been lost or the aliens have landed

class MainGame extends Phaser.Scene {

  constructor() {
    super('MainGame');    
    this.bullet;
    this.ship;
    this.rack;
    this.saucer;
    this.ground;
    this.score;
    this.level;

  }
  
  static Status = {
    INIT: 0, // call at the beginning of new game
    STANDBY: 1, // the aliens are being spawned but spawning not yet complete
    PLAYING: 2,
    RESPAWN: 3,
    LEVELCLEAR: 4,
    GAMEOVER: 5
  }

The different states are "set" via the setStatus method


  setStatus(newStatus) {
    this.status = newStatus;
    switch(this.status) {
      case MainGame.Status.INIT:
        this.ground.layGround()
        this.score = 0;
        this.level = 1;
        this.status = MainGame.Status.STANDBY; 
        break;
      case MainGame.Status.RESPAWN:
        this.time.delayedCall(MainGame.respawnLabelOnScreen, this.displayMSG, [this.respawnLabel], this)
        this.time.delayedCall(MainGame.playerRespawnDelay, this.respawnShip, [], this)
        break;
      case MainGame.Status.GAMEOVER:
        this.time.delayedCall(MainGame.gameOverLabelOnScreen, this.displayMSG, [this.gameOverLabel], this)
        this.time.delayedCall(MainGame.gameOverDelay, this.restartGame, [], this)
        break;
      case MainGame.Status.LEVELCLEAR:
        this.time.delayedCall(MainGame.respawnLabelOnScreen, this.displayMSG, [this.respawnLabel], this);
        this.time.delayedCall(MainGame.playerRespawnDelay, this.startNextLevel, [], this)
        break;
    }
  }

The update function polls 2 states: STANDBY, and PLAYING


update (time, delta) {
    switch (this.status) {
      case MainGame.Status.STANDBY:
        this.rack.update(this);
        // wait until all the invaders have been spawned and are in place before spawning the player
        if (this.rack.status !== Rack.Status.FORMING) {
          this.ship.setStatus(Player.Status.NORMAL);
          this.status = MainGame.Status.PLAYING;
        }
        break;
      case MainGame.Status.PLAYING:
        this.rack.update(this);
        if (this.rack.liveInvaders === 0) {
          this.setStatus(MainGame.Status.LEVELCLEAR);           
        };
        if (this.rack.status === Rack.Status.LANDED) {
          this.setStatus(MainGame.Status.GAMEOVER);
        };
        break;
      }
  }

The CODEPEN is below. Note, all it does is move the aliens around, and the player can shoot (but the bullets will fly straight past the aliens).




5 views0 comments
記事: Blog2_Post
bottom of page