top of page
Search
  • cedarcantab

Space Invaders in Phaser 3 (Part 5): Drawing and Moving the Invaders

Updated: Jan 14, 2022

Now we start getting into the guts of the game. Drawing and moving the aliens.



For those who have played the actual game, the behaviour of the aliens should be familiar. They move in unison horizontally until they hit the side then shift down one row.


Computer Archeology ("CA"), gives the following more precise description of the alien "rack".

There are 5 rows of 11 aliens in Space Invaders. There are three types of aliens. Each type has two pictures that flip to make animation with each "step".

The end-screen interrupt draws precisely one alien each screen painting. The lower left alien moves left or right 2 pixels and the code redraws it. On the next pass (interrupt) the alien to the right of the reference moves. The wave continues each screen refresh (60 times a second) from left to right and bottom to top. Once all live aliens have been drawn the process starts again. When the rack hits the left or right side of the screen the direction reverses and the reference alien is dropped 8 pixels.

The more aliens there are on the screen the longer it takes to get back around to moving the reference alien. At the start of the round there are 55 aliens. That's 55 interrupts or almost 1 second to move the entire rack. 

Using Phaser 3's sprite object and animations, it would be very simple to write code to move 55 aliens every frame all with their arms flapping at a constant rate. However, as described above, 1) each alien moves with each screen refresh, and 2) the arms flap with each "step".


As to how quickly each alien moves:

At the end of the round there is only one alien left. It moves 2 pixels left or right 60 times a second. That's about two seconds from side to side.

The game does a nasty trick to the timing when there is one alien left. Instead of moving 2 pixels both directions the alien moves 2 pixels at a time to the left but 3 pixels at a time to the right. The last little alien is faster going right than it is going left. 

CA goes onto give details about how the Y-coordinate of the rack of aliens is set.


The table at 1DA3 gives the starting Y (rotated) coordinates for the alien rack with each new round.

;##-AlienStartTable
; Starting Y coordinates for aliens at beginning of rounds. The first round is initialized to $78 at 07EA.
; After that this table is used for 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, and 9th. The 10th starts over at
; 1DA3 (60).
1DA3: 60                                    
1DA4: 50                                    
1DA5: 48                                    
1DA6: 48                                    
1DA7: 48                                    
1DA8: 40                                    
1DA9: 40                                    
1DAA: 40   
The first wave starts at Y=78 (hex). The next wave starts 16 pixels (one row of the rack) lower at Y=50. Then the rack holds at 48 for three rounds and then 40 for three rounds. The value Y=40 is just above the player's shields.

Round 7 is as hard as the game gets. By that time the player's score is above 3000, which means the aliens reload their shots at the fastest rate. The aliens never start out lower than Y=40.

This explanation is clear enough. However (and if I have correctly understood the raster screen coordinate system), the y-coordinate of the bottom left alien in the first round should be 0x78 (hex) or 120 pixels from the bottom (0,0 being the bottom left hand corner, with the screen rotated) or in the Javascript world, Y should be 136 (=SCREEN_HEIGHT (256) - 120). In the second wave, the bottom left alien should start at Y of 0x60 or 96 or in the Javascript world 160, and so on.


Original Y in Hex 78 60 50 48 40

Original Y in Dec 120 96 80 72 64

Javascript Y 136 160 176 184 192


The above is certainly plenty of information to allow us to mimic the movement, but let's look at the code in a bit more detail.


Object descriptor


CA does not refer to the alien as a game object, like the player or player shot. However, there appears to be a "descriptor" table for the alien(s) also.


This table confirms the following, which supports (as one would expect) the description by CA:

  • The delta-X of the aliens is 2-pixels

  • The (intial) Y-coordinate of the reference alien is $78 (=120)

  • The (initial) X-coordinate of the refence alien appears to be $38 (56). As with the case of the player object, this does not look correct, as it puts the "rack" pretty much at the right-edge of the screen from the start. If anybody can tell me where I am reading the code incorrectly, I would be grateful.

  • The rackDownDelta is $F8, which in the 8-bit world is taken as -8.

The code to draw the aliens, and more importantly, put the rack on the screen and move them in the familiar fashion is very long but very well commented by CA; hence I am not going to reproduce it here in the way I did with the player game object. However, the following code is worth looking at, as it is the key piece of code which "moves" the individual aliens, hence the "rack".


Essentially, every refresh, one alien is moved according to the logic, and the particular alien to be moved is referenced by the counter referred by CA as alienCurIndex. As you can see by the RED highlighted code, this counter is checked against $37 (or 55) which is the total number of aliens in the rack (11x5 = 55 = $37).


The fact that only one alien is updated every refresh as opposed to moving the entire rack of aliens in one go is crucial in achieving the "rippling" maching effect as well as the speeding up effect.

CursorNextAlien:
; This is called from the mid-screen ISR to set the cursor for the next alien to draw.
; When the cursor moves over all aliens then it is reset to the beginning and the reference
; alien is moved to its next position.
;
; The flag at 2000 keeps this in sync with the alien-draw routine called from the end-screen ISR.
; When the cursor is moved here then the flag at 2000 is set to 1. This routine will not change
; the cursor until the alien-draw routine at 100 clears the flag. Thus no alien is skipped.
;
0141: 3A 68 20        LD      A,(playerOK)        ; Is the player ...
0144: A7              AND     A                   ; ... blowing up?
0145: C8              RET     Z                   ; Yes ... ignore the aliens
0146: 3A 00 20        LD      A,(waitOnDraw)      ; Still waiting on ...
0149: A7              AND     A                   ; ... this alien to be drawn?
014A: C0              RET     NZ                  ; Yes ... leave cursor in place
014B: 3A 67 20        LD      A,(playerDataMSB)   ; Load alien-data ...
014E: 67              LD      H,A                 ; ... MSB (either 21xx or 22xx)
014F: 3A 06 20        LD      A,(alienCurIndex)   ; Load the xx part of the alien flag pointer
0152: 16 02           LD      D,$02               ; When all are gone this triggers 1A1 to return from this stack frame
0154: 3C              INC     A                   ; Have we drawn all aliens ...
0155: FE 37           CP      $37                 ; ... at last position?
0157: CC A1 01        CALL    Z,MoveRefAlien      ; Yes ... move the bottom/right alien and reset index to 0
015A: 6F              LD      L,A                 ; HL now points to alien flag
015B: 46              LD      B,(HL)              ; Is alien ...
015C: 05              DEC     B                   ; ... alive?
015D: C2 54 01        JP      NZ,$0154            ; No ... skip to next alien
0160: 32 06 20        LD      (alienCurIndex),A   ; New alien index
0163: CD 7A 01        CALL    GetAlienCoords      ; Calculate bit position and type for index
0166: 61              LD      H,C                 ; The calculation returns the MSB in C
0167: 22 0B 20        LD      (alienPosLSB),HL    ; Store new bit position
016A: 7D              LD      A,L                 ; Has this alien ...
016B: FE 28           CP      $28                 ; ... reached the end of screen?
016D: DA 71 19        JP      C,$1971             ; Yes ... kill the player
0170: 7A              LD      A,D                 ; This alien's ...
0171: 32 04 20        LD      (alienRow),A        ; ... row index
0174: 3E 01           LD      A,$01               ; Set the wait-flag for the ...
0176: 32 00 20        LD      (waitOnDraw),A      ; ... draw-alien routine to clear
0179: C9              RET                         ; Done

The BLUE line is checking the X-coordinate (I think) of the alien after its been moved against $28 (=40), and if this condition is met, the player is killed. I assume that 40-pixels from the bottom of the screen is when the aliens are deemed to have "landed". In the Javascript world, this translates to 256-40+half of the alien sprite height = 216+4 = 220.


3 Different types of Alien Images

There are 3 different types of aliens, typically referred to as squid, crab and octopus.







Looking at the "DrawAlien" code of the original, which image to use is determined by the row.


011C: E6 FE           AND     $FE                 ; Translate row to type offset as follows: ...
011E: 07              RLCA                        ; ... 0,1 -> 32 (type 1) ...
011F: 07              RLCA                        ; ... 2,3 -> 16 (type 2) ...
0120: 07              RLCA                        ; ...   4 -> 32 (type 3) on top row

Specifically, the octopus occupy the top row, the crab occupy the middle 2 rows, and the crabs occupy the bottom 2 rows. They earn the player different points when shot.


Different Scores for each alien when shot

The following code shows that the score obtained by hitting the aliens depends on the type/


AlienScoreValue:
097C: 21 A0 1D        LD      HL,$1DA0            ; Table for scores for hitting alien
097F: FE 02           CP      $02                 ; 0 or 1 (lower two rows) ...
0981: D8              RET     C                   ; ... return HL points to value 10
0982: 23              INC     HL                  ; next value
0983: FE 04           CP      $04                 ; 2 or 3 (middle two rows) ...
0985: D8              RET     C                   ; ... return HL points to value 20
0986: 23              INC     HL                  ; Top row ...
0987: C9              RET                         ; ... return HL points to value 30

Register HL is first loaded with $1DA0 which is where the following AlienScores table is held. Then it goes through a bunch of if statements to get the relevant score from the following table.

AlienScores:
; Score table for hitting alien type
1DA0: 10 ; Bottom 2 rows
1DA1: 20 ; Middle row
1DA2: 30 ; Highest row

In my Javascript version, I have simply created a Javascript object as below, and access the relevant score via the alien type.


static POINT_TABLE = {'squid': 30, 'crab': 20, 'octopus': 10};

Spacing of each alien

And the following code from "GetAlienCoords" suggets (I think) each alien should be offset from each other by 16 pixels in both x and y directions.


018B: 78              LD      A,B                 ; Add ...
018C: C6 10           ADD     A,$10               ; ... 16 to bit ...
018E: 47              LD      B,A                 ; ... position Y (1 row in rack)
018F: 7B              LD      A,E                 ; Restore tallied index
0190: 14              INC     D                   ; Next row
0191: C3 83 01        JP      $0183               ; Keep skipping whole rows
;
0194: 68              LD      L,B                 ; We have the LSB (the row)
0195: A7              AND     A                   ; Are we in the right column?
0196: C8              RET     Z                   ; Yes ... X and Y are right
0197: 5F              LD      E,A                 ; Hold index
0198: 79              LD      A,C                 ; Add ...
0199: C6 10           ADD     A,$10               ; ... 16 to bit ...
019B: 4F              LD      C,A                 ; ... position X (1 column in rack)

The explosion of the alien consists of the single image below, opposed an animation of multiple frames.





Extended Phaser Sprite object for individual invader

Based on the above, I have created a Phaser.Sprite extended called called Invader to handle individual aliens. I could have written extended objects for each of the different aliens, but given that apart from the different scores, they behave in exactly the same way, I have only created only one class and assigned different points to each depending on the texture type.


There are a number methods for this object but the key ones are:

  1. lateralMove, which moves the invader left or right, and

  2. shiftDown, which moves the invader down one row. The delta for the drop is defined in the Invader class a static variable DELTA_Y, whereas the delta for lateral movements is passed as a parameter to the method, from the "rack" class (to be explained later) which is the Phaser.Group object that controls the group of invaders.


Each time these methods are called, the frame is flipped between 0 and 1. Phaser 3 animation is deliberately not used in order to be more faithful to the original.


class Invader extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y, texture) {
  super(scene, x, y, texture);
    this.scene = scene;
    scene.add.existing(this);
    scene.physics.add.existing(this);
    this.status;
    this.type = texture; 
    this.currentFrame;;
    this.points = Invader.POINT_TABLE[this.type];
    this.setStatus(Invader.Status.STANDBY);
  }
 
  static Status = {
    STANDBY: 0,
    SPAWN: 1,
    ALIVE: 2,
    EXPLODE: 3
  }
  static POINT_TABLE = {'squid': 30, 'crab': 20, 'octopus': 10};
  static EXPLOSION_DURATION = 500;
  static DELTA_Y = 8;
  
  setStatus(newStatus) {
    this.status = newStatus;
    switch (newStatus) { 
      case Invader.Status.STANDBY:
        this.disableBody(true,true);
        this.currentFrame = 0;
        break;
      case Invader.Status.SPAWN:
        this.currentFrame = 0;
        this.setTexture(this.type);
        this.status = Invader.Status.ALIVE;
        break;
      case Invader.Status.EXPLODE:
        this.setTexture('invaderExplode');
        this.scene.time.delayedCall(Invader.EXPLOSION_DURATION, this.setStatus, [Invader.Status.STANDBY], this);
        break;
    }
  }  
  
  activate(x,y) {
    this.enableBody(true, x ,y, true, true);
    this.status = Invader.Status.ALIVE;
  }
  
  isAlive() {
    return (this.status === Invader.Status.ALIVE)
  }
  
  lateralMove(direction) {
    this.currentFrame = (this.currentFrame === 0 ? 1 : 0)
    this.x += (direction > 0 ? this.scene.rack.rightDeltaX : this.scene.rack.leftDeltaX);
    this.setFrame(this.currentFrame)
  }
  
  shiftDown() {
    this.currentFrame = (this.currentFrame === 0 ? 1 : 0)
    this.y += Invader.DELTA_Y;
    this.setFrame(this.currentFrame)
  }
} 

Create a Phaser Group to handle the 55 aliens as a unit

As can be seen from the above, the individual invader object does not have a lot of "intelligence". The "brains" is included in the below class which is an extended Phaser Group object called "rack". This object essentially handle the state of the 55 aliens as a group.


Looking at the original code, the following appears to be key in dictating the behaviour of the "rack" when it hits the edge of the screen.


rackDirection determines the direction in which the rack is moving (set to 1 for right, and 0 for left). The RED line which sets the x-delta to -2 is interesting since register B is actually being set to $FE which is 254. Apparently, this is -2.


RackBump:
; When rack bumps the edge of the screen then the direction flips and the rack
; drops 8 pixels. The deltaX and deltaY values are changed here. Interestingly
; if there is only one alien left then the right value is 3 instead of the
; usual 2. The left direction is always -2.
1597: 3A 0D 20        LD      A,(rackDirection)   ; Get rack direction
159A: A7              AND     A                   ; Moving right?
159B: C2 B7 15        JP      NZ,$15B7            ; No ... handle moving left
;
159E: 21 A4 3E        LD      HL,$3EA4            ; Line down the right edge of playfield
15A1: CD C5 15        CALL    $15C5               ; Check line down the edge
15A4: D0              RET     NC                  ; Nothing is there ... return
15A5: 06 FE           LD      B,$FE               ; Delta X of -2
15A7: 3E 01           LD      A,$01               ; Rack now moving right
;
15A9: 32 0D 20        LD      (rackDirection),A   ; Set new rack direction
15AC: 78              LD      A,B                 ; B has delta X
15AD: 32 08 20        LD      (refAlienDXr),A     ; Set new delta X
15B0: 3A 0E 20        LD      A,(rackDownDelta)   ; Set delta Y ...
15B3: 32 07 20        LD      (refAlienDYr),A     ; ... to drop rack by 8
15B6: C9              RET                         ; Done
;
15B7: 21 24 25        LD      HL,$2524            ; Line down the left edge of playfield
15BA: CD C5 15        CALL    $15C5               ; Check line down the edge
15BD: D0              RET     NC                  ; Nothing is there ... return
15BE: CD F1 18        CALL    $18F1               ; Get moving-right delta X value of 2 (3 if just one alien left)
15C1: AF              XOR     A                   ; Rack now moving left
15C2: C3 A9 15        JP      $15A9               ; Set rack direction

Code to set the x-delta depending on the number of remaining aliens


The x-delta in the right direction changes from 2 to 3, when there is only one alien. Note, this is only in the right direction. Hence the need for the following piece of code. In my Javascript version, I have simply created 2 variables - this.rightDeltaX and this.leftDeltaX, within the "rack" class, as opposed to the "invader" class.


; If there is one alien left then the right motion is 3 instead of 2. That's
; why the timing is hard to hit after the change.
18F1: 06 02           LD      B,$02               ; Rack moving right delta X
18F3: 3A 82 20        LD      A,(numAliens)       ; Number of aliens on screen
18F6: 3D              DEC     A                   ; Just one left?
18F7: C0              RET     NZ                  ; No ... use right delta X of 2
18F8: 04              INC     B                   ; Just one alien ... move right at 3 instead of 2
18F9: C9              RET                         ; Done


Detecting when the rack has reached the edge of the screen

In the above code, the starting point for checking whether the rack has reached the edge is to load the H-register with $3EA4 for right and $2524 for left. I assume these are particular bytes (ie a 8 pixel vertical line in the rotated SI screen) in the video RAM. $3EA4 would be 11 pixels from the right, 5th byte from the bottom (which in turn means the 1st bit would be 32nd pixel from the bottom), and $2524 would be 10 pixels from the left, 5th byte from the bottom.


Then the following code is called, which increases the address by 1 (i.e. moves to the next "byte" up the screen), $17 (23) times (there are 32 bytes "up" the rotated SI screen), checking whether there is anything there. This is a fascinating way to detect whether any alien in the rack has reached the edge. I certainly did not try to recreate this kind of edge detection (even if I wanted to, I do not know the way to read the content of a particular pixel/block of pixels on a screen in Javascript), instead simply looping through the entire group of aliens and looking at the biggest (or smallest) x-coordinate, and comparing that against the edge.


But it does tell us that the rack of aliens are not allowed to go right up against the edge; instead they turn direction at 10/11 pixels on each side. Also (if I have understood it right) it automatically takes care of the fact that the "width" of the aliens are not all the same, with the "squid" being the narrowest. In the case of my code I have created the sprite textures to capture just the "filled" part of the image, hence the sizes are different:

  • squid: 8x8

  • octopus: 12x8

  • crab: 11x8

I have simply assumed that when the x-coordinate of the most left alien is 8 pixels from the edge or when the most right alien is 8 pixels from the right edge of the screen, then the rack shifts down. This is close enough to the original.


15C5: 06 17           LD      B,$17               ; Checking 23 bytes in a line up the screen from near the bottom
15C7: 7E              LD      A,(HL)              ; Get screen memory
15C8: A7              AND     A                   ; Is screen memory empty?
15C9: C2 6B 16        JP      NZ,$166B            ; No ... set carry flag and out
15CC: 23              INC     HL                  ; Next byte on screen
15CD: 05              DEC     B                   ; All column done?
15CE: C2 C7 15        JP      NZ,$15C7            ; No ... keep looking
15D1: C9              RET                         ; Return with carry flag clear

Writing the above in Javascript/ Phaser!


class Rack extends Phaser.Physics.Arcade.Group {
	constructor(scene, config) {
    super(scene.physics.world, scene, config);  
    this.status;
    this.level;
    this.liveInvaders;
    this.bottomLeft;
    this.invaderID; // used to keep track of which alien to move next
    this.direction; // track direction in which aliens are moving. 1 = right, -1 = left
    this.enemyType;    
    this.createInvaders(scene); // instantiate x55 of the individual invader objects
    this.startLevel(1);
  }

  static Status = {  
    INIT: 0,
    FORMING: 1,
    MARCHING: 2,
    SHIFTFOWN: 3,
    LANDED: 4
  }
  static ENEMY_TYPE = ["octopus", "octopus", "crab", "crab", "squid"];
  static STARTING_X = 16;
  static STARTING_Y = [136, 160, 176, 184, 192];
  static BOTTOM = 220;
  
  update(scene) {
    switch (this.status) {
      case Rack.Status.FORMING:
        this.handleFormation(scene);
        break;
      case Rack.Status.MARCHING:
        this.handleMarching(scene);
        break;  
      case Rack.Status.SHIFTDOWN:
        this.handleShiftDown(scene);
        break;
      default:
        break;
    }
  }

  setStatus(newStatus) {
    this.status = newStatus;
    switch (this.status) {
      case Rack.Status.INIT:
        this.liveInvaders = 55;
        this.invaderID = 0;
        this.direction = 1;
        this.leftDeltaX = -2;
        this.rightDeltaX = 2;
        this.bottomLeft = {x: Rack.STARTING_X, y: Rack.STARTING_Y[Math.min(8,this.level-1)]};
        this.children.each((alien) => alien.setStatus(Invader.Status.SPAWN)); // reset the texture, frame of each alien and set status to ALIVE
        this.setStatus(Rack.Status.FORMING);
        break;
      case Rack.Status.MARCHING:
        this.invaderID = 0;
        break;
      case Rack.Status.SHIFTDOWN:
        this.direction *=-1
        break;
      case Rack.Status.LANDED:
        break;
    }
  }
   
  invaderHit() {
    this.liveInvaders --;
    if (this.liveInvaders <= 1) this.rightDeltaX = 3;
  }
  
  createInvaders(scene) {
    // instantiate the individual invaders, starting with the bottom 2 rows, then the middle 2 rows and finally the top row
    // order of instantiation matters since their order in the group 'Array' determines the position of each alien
    this.createMultiple({classType: Invader, key: 'octopus', quantity: 22});
    this.createMultiple({classType: Invader, key: 'crab', quantity: 22});
    this.createMultiple({classType: Invader, key: 'squid', quantity: 11});
  }
  
  startLevel(level) {
    this.level = level;
    this.setStatus(Rack.Status.INIT);
  }
  
  handleFormation(scene) {
    // calculate initial position from index number
    const col = this.invaderID % 11 ;
    const row = Math.floor(this.invaderID / 11);  
    this.enemyType = Rack.ENEMY_TYPE[row];
    let enemy = this.getChildren()[this.invaderID];
    enemy.activate(this.bottomLeft.x + col*16,this.bottomLeft.y -row*16);
    this.invaderID ++;
    if (this.invaderID >= 55) {
      this.setStatus(Rack.Status.MARCHING)
    }    
  }
  
  handleMarching(scene) {
    const alien = this.getNextAlien();
    if (alien.isAlive()) {
      alien.lateralMove(this.direction);      
    }
    this.invaderID++;
    if (this.invaderID > 54) {
      this.invaderID = 0;
      if ((this.direction === 1 && this.mostRight() > SCREEN_WIDTH-8) || (this.direction === -1 && this.mostLeft() < 8)) {
        this.setStatus(Rack.Status.SHIFTDOWN);
      }
    }
  }
  
  handleShiftDown(scene) {
    const alien = this.getNextAlien()
    if (alien.isAlive()) {
      alien.shiftDown();      
    }
    this.invaderID ++;
    if (this.invaderID > 54) {
      if (this.mostBottom() > Rack.BOTTOM) {
        this.setStatus(Rack.Status.LANDED);
      } else {
        this.setStatus(Rack.Status.MARCHING);
      }
    }
  }

  getNextAlien() {
    const aliens = this.getChildren();
    while (!aliens[this.invaderID].isAlive() && this.invaderID<54) {
      this.invaderID ++;
    }
    return aliens[this.invaderID];
  }
  
  mostBottom() {
    return Math.max(...this.getMatching('status', Invader.Status.ALIVE).map((child) =>child.y));
  }
  
  mostLeft() {
    return Math.min(...this.getMatching('status', Invader.Status.ALIVE).map((child) =>child.x));
  }

  mostRight() {
    return Math.max(...this.getMatching('status', Invader.Status.ALIVE).map((child) =>child.x));
  }
 
}

The above code is available in this CODEPEN.



5 views0 comments
記事: Blog2_Post
bottom of page