top of page
Search
cedarcantab

Space Invaders in Phaser 3 (Part 13): Flying Saucer

Updated: Jan 21, 2022

In this post I talk about how I attempt to recreate the flying saucer.



Computer Archeology gives has the following to say about the flying saucer.


The flying saucer shares the object-task with the "squiggly" shot. Only one of them can be on the screen at a time. The main loop keeps up with the time-until-saucer by calling the following piece of code.


TimeToSaucer:
0913: 3A 09 20        LD      A,(refAlienYr)      ; Reference alien's X coordinate
0916: FE 78           CP      $78                 ; Don't process saucer timer ... ($78 is 1st rack Yr)
0918: D0              RET     NC                  ; ... unless aliens are closer to bottom
0919: 2A 91 20        LD      HL,(tillSaucerLSB)  ; Time to saucer
091C: 7D              LD      A,L                 ; Is it time ...
091D: B4              OR      H                   ; ... for a saucer
091E: C2 29 09        JP      NZ,$0929            ; No ... skip flagging
0921: 21 00 06        LD      HL,$0600            ; Reset timer to 600 game loops
0924: 3E 01           LD      A,$01               ; Flag a ...
0926: 32 83 20        LD      (saucerStart),A     ; ... saucer sequence
0929: 2B              DEC     HL                  ; Decrement the ...
092A: 22 91 20        LD      (tillSaucerLSB),HL  ; ... time-to-saucer
092D: C9              RET                         ; Done

$600 = 1536, which in terms of frames would be 1536x(1/60) = 25.6 seconds.


Spawning the saucer

The beginning of the code of Game task 4 is reproduced below.


First the "tillSaucer" flag (held at $2083) is checked. If it isn't time or if there is already a squiggly shot going then it handles the squiggly shot. If there are 8 or more aliens on the screen then a saucer begins its journey across the screen.


GameObj4:
; Game object 4: Flying Saucer OR squiggly shot
;
; This task is shared by the squiggly-shot and the flying saucer. The saucer waits until the
; squiggly-shot is over before it begins.
;
0682: E1              POP     HL                  ; Pull data pointer from the stack (not going to use it)
0683: 3A 80 20        LD      A,(shotSync)        ; Sync flag (copied from GO-2's timer value)
0686: FE 02           CP      $02                 ; Are GO-2 and GO-3 idle?
0688: C0              RET     NZ                  ; No ... only one at a time
0689: 21 83 20        LD      HL,$2083            ; Time-till-saucer flag
068C: 7E              LD      A,(HL)              ; Is it time ...
068D: A7              AND     A                   ; ... for a saucer?
068E: CA 0F 05        JP      Z,$050F             ; No ... go process squiggly shot
0691: 3A 56 20        LD      A,(squShotStepCnt)  ; Is there a ...
0694: A7              AND     A                   ; ... squiggly shot going?
0695: C2 0F 05        JP      NZ,$050F            ; Yes ... go handle squiggly shot

0698: 23              INC     HL                  ; Saucer on screen flag
0699: 7E              LD      A,(HL)              ; (2084) Is the saucer ...
069A: A7              AND     A                   ; ... already on the screen?
069B: C2 AB 06        JP      NZ,$06AB            ; Yes ... go handle it
069E: 3A 82 20        LD      A,(numAliens)       ; Number of aliens remaining
06A1: FE 08           CP      $08                 ; Less than ...
06A3: DA 0F 05        JP      C,$050F             ; ... 8 ... no saucer
06A6: 36 01           LD      (HL),$01            ; (2084) The saucer is on the screen
06A8: CD 3C 07        CALL    $073C               ; Draw the flying saucer

1Then the routine to draw the saucer is called


Drawing the saucer

As we have seen before, ReadDesc reads the object descriptor at the RAM location indicated by HL.


073C: CD 42 07        CALL    $0742               ; Draw the ...
073F: C3 39 14        JP      DrawSimpSprite      ; ... flying saucer

0742: 21 87 20        LD      HL,$2087            ; Read flying saucer ...
0745: CD 3B 1A        CALL    ReadDesc            ; ... structure
0748: C3 47 1A        JP      ConvToScr           ; Convert pixel number to screen and shift and out

In this case, the game descriptor is located at $2087 and the content is described below. In particular, Yr is $D0 = 208, which in the Javascript world is 256-208=48. Given the center origin of Phaser sprites, that translates to 44.


How to determine the direction of the flying saucer

The flying saucer's direction is linked to the player's shot count. The lowest bit of the count determines which side of the screen the saucer comes from. If the saucer appears after an even number of player shots then it comes from the right. After an odd number it comes from the left. The saucer object structure is re-initialized every time the player's shot blows up. Here is the code in Object 1 (player fire):


045D: 3A 84 20        LD      A,($2084)           ; Is saucer ...
0460: A7              AND     A                   ; ... on screen?
0461: C0              RET     NZ                  ; Yes ... don't reset it
;
; Setup saucer direction for next trip
0462: 7E              LD      A,(HL)              ; Shot counter
0463: E6 01           AND     $01                 ; Lowest bit set?
0465: 01 29 02        LD      BC,$0229            ; Xr delta of 2 starting at Xr=29
0468: C2 6E 04        JP      NZ,$046E            ; Yes ... use 2/29
046B: 01 E0 FE        LD      BC,$FEE0            ; No ... Xr delta of -2 starting at Xr=E0
046E: 21 8A 20        LD      HL,$208A            ; Saucer descriptor
0471: 71              LD      (HL),C              ; Store Xr coordinate
0472: 23              INC     HL                  ; Point to ...
0473: 23              INC     HL                  ; ... delta Xr
0474: 70              LD      (HL),B              ; Store delta Xr
0475: C9              RET                         ; Done

Edge detection

A bit further down the game object task is the following code to move and detect when the saucer has reached the edge of the screen.


The code in BLUE first gets the Y-coordinate (unrotated screen, hence X-coordinate in the rotated screen) held (apparently) in $208A and increment that by delta-Y (done by incrementing by one twice), and then the routine to re-draw the saucer is called.


The code in RED is the edge detection. The comment is all in terms of the unrotated screen - given the nature of the hardware, "too low" on the Y-coordinate means at the right hand edge, and "too high" means left edge.($28 = 40) $E1=

06BA: 21 8A 20        LD      HL,$208A            ; Saucer's structure
06BD: 7E              LD      A,(HL)              ; Get saucer's Y coordinate
06BE: 23              INC     HL                  ; Bump to ...
06BF: 23              INC     HL                  ; ... delta Y
06C0: 86              ADD     A,(HL)              ; Move saucer
06C1: 32 8A 20        LD      (saucerPriPicMSB),A ; New coordinate
06C4: CD 3C 07        CALL    $073C               ; Draw the flying saucer
06C7: 21 8A 20        LD      HL,$208A            ; Saucer's structure
06CA: 7E              LD      A,(HL)              ; Y coordinate
06CB: FE 28           CP      $28                 ; Too low? End of screen?
06CD: DA F9 06        JP      C,$06F9             ; Yes ... remove from play
06D0: FE E1           CP      $E1                 ; Too high? End of screen?
06D2: D2 F9 06        JP      NC,$06F9            ; Yes ... remove from play
06D5: C9              RET                         ; Done

The "hit" sequence for saucer is a little bit complicated

In pretty much all other game objects when they explode, the sprite is replaced by an exlosion image which remains for a predetermined period of time, according to a count-down timer. When the timer reaches zero the image is cleared. In the case of the saucer, there is an explosion image, but after a while that is replaced by the score that's been awarded. The following code appears further down the game object task.


According to this, it lokos like the count down timer for the "explosion" sequence starts at $1F = 31 = 31x60 refresh = 1860 milli-seconds, then the score is draw when the timer is $18=24 refresh = 24x60=1,440 (ie 420 milli-seconds after the explosion starts).

06D6: 06 FE           LD      B,$FE               ; Turn off ...
06D8: CD DC 19        CALL    SoundBits3Off       ; ... flying saucer sound
06DB: 23              INC     HL                  ; (2086) show-hit timer
06DC: 35              DEC     (HL)                ; Count down show-hit timer
06DD: 7E              LD      A,(HL)              ; Get current value
06DE: FE 1F           CP      $1F                 ; Starts at 20 ... is this the first tick of show-hit timer?
06E0: CA 4B 07        JP      Z,$074B             ; Yes ... go show the explosion
06E3: FE 18           CP      $18                 ; A little later ...
06E5: CA 0C 07        JP      Z,$070C             ; ... show the score besides the saucer and add it
06E8: A7              AND     A                   ; Has timer expired?
06E9: C0              RET     NZ                  ; No ... let it run

Score for hitting saucer depends on player score

The score for shooting the saucer ranges from 50 to 300, and the exact value depends on the number of player shots fired. The table at 0x1D54 contains 16 score values (you add trailing "0" to every value to get the three digit score) as follows:


1D54: 10 05 05 10 15 10 10 05 30 10 10 10 05 15 10 05


This pointer is incremented every time the player-shot is removed.


EndOfBlowup:
0436: CD 30 04        CALL    ReadPlyShot         ; Read the shot structure
0439: CD 52 14        CALL    EraseShifted        ; Erase the player's shot
043C: 21 25 20        LD      HL,$2025            ; Reinit ...
043F: 11 25 1B        LD      DE,$1B25            ; ... shot structure ...
0442: 06 07           LD      B,$07               ; ... from ...
0444: CD 32 1A        CALL    BlockCopy           ; ... ROM mirror
0447: 2A 8D 20        LD      HL,(sauScoreLSB)    ; Get pointer to saucer-score table
044A: 2C              INC     L                   ; Every shot explosion advances it one
044B: 7D              LD      A,L                 ; Have we passed ...
044C: FE 63           CP      $63                 ; ... the end at 1D63 (bug! this should be $64 to cover all 16 values)
044E: DA 53 04        JP      C,$0453             ; No .... keep it
0451: 2E 54           LD      L,$54               ; Wrap back around to 1D54
0453: 22 8D 20        LD      (sauScoreLSB),HL    ; New score pointer
0456: 2A 8F 20        LD      HL,(shotCountLSB)   ; Increments with every shot ...
0459: 2C              INC     L                   ; ... but only LSB ** ...
045A: 22 8F 20        LD      (shotCountLSB),HL   ; ... used for saucer direction

Javascript/Phaser version

In Javascript, the easiest way to implement the above is to have a table within the saucer game object, and a method to get the relevant score based on the score.


  getPoints() {
    return Saucer.POINT_TABLE[this.scene.ship.shotsCount % 15]  
  }

Creating Javascript / Phaser 3 game object

Armed with the above knowledge, as we create a Saucer object, like so.


class Saucer extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    super(scene, x, y, 'saucer');
    this.scene = scene;
    scene.add.existing(this);
    scene.physics.add.existing(this);
    this.status;
    this.direction;
    this.scoreDisplay = this.scene.add.image(); // object for the score to be flashed when saucer is hit

    this.setStatus(Saucer.Status.STANDBY);
    this.setDirection(Saucer.Direction.RIGHT)
  }
 
  static Status = {
    STANDBY: 0,
    FLY: 1,
    EXPLODE: 2,
  }
  static Direction = {
    LEFT: -1,
    RIGHT: 1,
  }
  static ALTITUDE = 44;
  static FLYSPEED = 40; // delta-x is 2 pixels, updated every 3 frames so 2 x (60/3) = 40
  static POINT_TABLE = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100];
  static SCORE_GRAPHIC = {50:'50',100:'100',150:'150',300:'300'}
  static EXPLOSION_DURATION = 1860;
  static DISPLAY_SCORE_DELAY = 420;

  preUpdate(time, delta) {  
    super.preUpdate(time, delta);
    switch (this.status) {
      case Saucer.Status.FLY:
        this.checkEdge();
        break;
    }
  }
  
  checkEdge() {
    if ((this.direction === Saucer.Direction.RIGHT && this.x > SCREEN_WIDTH) || (this.direction ===  Saucer.Direction.LEFT && this.x < 0)) {
      this.setStatus(Saucer.Status.STANDBY)
    }  
  }
  
  setStatus(newStatus) {
    this.status = newStatus;
    switch (this.status) {
      case Saucer.Status.STANDBY:
        this.disableBody(true,true);
        this.scoreDisplay.setVisible(false)
        break;
      case Saucer.Status.FLY:
        if (this.scene.ship.shotsCount % 2 === 0) {
          this.setDirection(Saucer.Direction.RIGHT)
        } else {
          this.setDirection(Saucer.Direction.LEFT)
        }
        this.setTexture('saucer');
        if (this.direction === Saucer.Direction.RIGHT) {
          this.enableBody(true, 10, Saucer.ALTITUDE, true, true);    
          this.body.setVelocity(Saucer.FLYSPEED,0);
        } else {
          this.enableBody(true, SCREEN_WIDTH - 10, Saucer.ALTITUDE, true, true);    
          this.body.setVelocity(-Saucer.FLYSPEED,0);
        }
    }
  }
  
  setDirection(direction) {
    this.direction = direction;
  }
  
  getPoints() {
    return Saucer.POINT_TABLE[this.scene.ship.shotsCount % 15]  
  }
  
  isAlive() {
    return (this.status === Saucer.Status.FLY)  
  }
  
  hit(saucerpoint) {
    this.scoreDisplay.setTexture(Saucer.SCORE_GRAPHIC[saucerpoint]);
    this.scoreDisplay.setPosition(this.x,this.y)
    this.body.stop();
    this.status = Saucer.Status.EXPLODE;
    this.setTexture('saucerExplode'); 
    this.scene.time.delayedCall(Saucer.DISPLAY_SCORE_DELAY, this.displayScore, [],this);
    this.scene.time.delayedCall(Saucer.EXPLOSION_DURATION, this.setStatus, [Saucer.Status.STANDBY], this);
  }

  displayScore() {
    this.setVisible(false);   
    this.scoreDisplay.setVisible(true)
  }

}

Spawning the saucer

I created a handleSaucer method as follows. This gets called from the main game loop. It is not exactly the same as the original in that I have not included a check for the existence of the squiggly bullet nor have I included a check for the height of the alien rack. No particular reason, other than running out of energy.


  handleSaucer(delta) {
    if (this.saucer.status === Saucer.Status.STANDBY) {  
      this.saucerSpawnTimer += delta;
      if (this.saucerSpawnTimer > MainGame.TIMETOSAUCER) {
        this.saucer.setStatus(Saucer.Status.FLY);;
        this.saucerSpawnTimer = 0;
      }
    }
  }

With the above additions, the game is now pretty much compete in its functionality (at least for a 1 player game).


The CODEPEN up to and including the above can be found below:










2 views0 comments

Recent Posts

See All

p2 naive broadphase

var Broadphase = require('../collision/Broadphase'); module.exports = NaiveBroadphase; /** * Naive broadphase implementation. Does N^2...

sopiro motor constranit

import { Matrix2, Vector2 } from "./math.js"; import { RigidBody } from "./rigidbody.js"; import { Settings } from "./settings.js";...

Comments


記事: Blog2_Post
bottom of page