I this post I document how I re-created the player object.
A bit about how the original hardware/code moved objects around the screen
All the 'sprites' in the original game was created as 'pixel-by-pixel'. Computer Archeology has the following to say about the screen.
The game uses colored transparencies that overlay certain areas of the monitor. The player's area is green and the flying saucer line is red. The overlays are obvious when you see shots changing colors at the top and bottom of the screen.
Screen memory is organized as one-pixel-per-bit -- eight pixels per byte. All sprites in the game are one byte wide but many rows long (on the rotated screen they are all 8 bits tall but 8, 16, or more bits wide). Rarely does a game sprite align perfectly to a byte boundary in screen memory. Most of the time the image pixels have to be shifted. Some of the pixels fall in the first byte of screen memory and some spill over into the next byte. The 8080 instruction-set lacks multi-bit shifts to make sprite rendering practical. But the SI hardware includes a 2-byte shift register mapped into port memory. The sprite drawing hardware runs all images through the shift register for a very fast bit-mapping.
In other words (I think) because the hardware worked in 8 bits at a time (byte) it's easy to display (1 "row" of an image) at 8 bit intervals (i.e. a horizontal "line" of a raster screen can be thought of as a 28 x 8 bits = 224 bits) but bit more tricky to display an image overlapping a particular byte - however, the "sprite drawing hardware" took care of objects that spilled over across 2 bytes.
And with reference to where to draw the player (or indeed any of the game objects), the following paragraph is relevant.
Each object has an X,Y coordinate in its specific data. The upper bit of the Y coordinate defines which half of the non-rotated screen the sprite is on. If the upper bit is 0 then the Y value is 0-127. If the upper bit is 1 then the Y value is 128-255 (223 is the screen limit).
and goes onto say..
The player object is unique in that code only calls it with the mid-screen interrupt no matter which half of the screen the player is on. ... The player runs every screen refresh. That's as fast as it can be.
Phew! In Phaser 3, we don't have to worry about any of that. We can simply use Phaser's built-in Sprite object. However, in order to analyse the game logic, it is necessary to have some understanding how the original code handles game objects.
Definition of game objects in the original code
CA includes the following text regarding game objects in the original code.
Each task has a 16 byte data structure. The first three bytes of each structure are timers that the system uses to decide how often to run a task....
The remaining bytes in each object structure are object-specific data.
Each object has an X,Y coordinate in its specific data. The upper bit of the Y coordinate defines which half of the non-rotated screen the sprite is on. If the upper bit is 0 then the Y value is 0-127. If the upper bit is 1 then the Y value is 128-255 (223 is the screen limit). The first thing the interrupts do is set a bit identifying which one is running. Both interrupts call the task routines, but the routines only run in the context of one of them based on their Y coordinate.
The section of RAM used to hold this 16 byte game object descriptor for the Player object (referred to by CA as game object 0) starts at $2010. As will become apparently, the ROM holds the "initial" state of this 16 byte descriptor, that gets copied over to the RAM every time it needs to be reset.
The above descriptor table gives us a number of clues in as we dig into the code.
the "task" for handling object 0 starts at $028E
the explosion related timer comprises (i) counter to flip between the 2 frames (see below) of the explosion animation - set initially to 5, and (ii) counter for the "number of changes left in blow-up sequence" that is set at $0C = 12.
the image texture for the player is stored from $1C60
the default Y-coordinate for the player is $20 = 32 (ie 32 pixels from the bottom of the 256 row screen). This means that in our Javascript/Phaser environment of sprites with origins in their centre, the default coordinate should be 256-32+4=228.
the default X-coordinate for the player is $30 = 48. This means that in our Javascript/Phaser environment, X-coordinate should be 48+6 (since our Phaser player sprite is only 13 pixels wide) = 54. This frankly, looks far to the right, when looking at YouTube videos. If anyone can tell me what I have read wrong, I would be very grateful to know.
the image texture consists of 16 bytes (or rows) of data
Before digging into the code to handle the object, let's look at the sprite images.
Player and Player Explosion Sprites
The player and it's explosion images are as shown below (copied from the Computer Archeology page).
The texture for the player, as indicated above, starts from $1C60. The 2 explosion animation frames follow the player sprite, starting at $1C70, and $1C80.
Each sprite works in multiple of 8-bits (or 1 bit in the case of the bullet), but of course, using Phaser 3 sprites, we don't have to worry about any of that. So that we can use the standard collision detection function of Phaser without worrying about resetting the hit-boxes, I have cropped the player image to 13x8 pixels (ie deleted the exraneous spaces on both sides).
Now, let's look at the code that handles game object 0, which starts at $028E.
Code to Handle inputs
Looking at the code in a bit more tail, there's a chunk of code to handle inputs. I will not copy all the code from CA but will comment my understanding of the beginning of the code. The first thing it appears to do, is to check the "status" of the game object - 6th byte of the descriptor table - if it is in normal state, it is set to $FF.
GameObj0:
; Game object 0: Move/draw the player
;
; This task is only called at the mid-screen ISR. It ALWAYS does its work here, even though
; the player can be on the top or bottom of the screen (not rotated).
;
028E: E1 POP HL ; Get player object structure 2014
028F: 23 INC HL ; Point to blow-up status
0290: 7E LD A,(HL) ; Get player blow-up status
0291: FE FF CP $FF ; Player is blowing up?
0293: CA 3B 03 JP Z,$033B ; No ... go do normal movement
At $033B, there's a bunch of code to read the inputs, which in turn calls code to move the player right or left.
Moving left and right
; Player not blowing up ... handle inputs
033B: 21 68 20 LD HL,$2068 ; Player OK flag
033E: 36 01 LD (HL),$01 ; Flag 1 ... player is OK
0340: 23 INC HL ; 2069
0341: 7E LD A,(HL) ; Alien shots enabled?
0342: A7 AND A ; Set flags
0343: C3 B0 03 JP $03B0 ; Continue
0346: 00 NOP ; ** Why?
0347: 2B DEC HL ; 2069
0348: 36 01 LD (HL),$01 ; Enable alien fire
034A: 3A 1B 20 LD A,(playerXr) ; Current player coordinates
034D: 47 LD B,A ; Hold it
034E: 3A EF 20 LD A,(gameMode) ; Are we in ...
0351: A7 AND A ; ... game mode?
0352: C2 63 03 JP NZ,$0363 ; Yes ... use switches as player controls
;
0355: 3A 1D 20 LD A,(nextDemoCmd) ; Get demo command
0358: 0F RRCA ; Is it right?
0359: DA 81 03 JP C,MovePlayerRight ; Yes ... do right
035C: 0F RRCA ; Is it left?
035D: DA 8E 03 JP C,MovePlayerLeft ; Yes ... do left
The RED lines appear to be getting the player's current X-coordinate (which is held in variable playerXr or $201B) , storing it in register-A, then sticks that into register-B.
Draw the Sprite
This is followed by code to display the player object.
; Draw player sprite
036F: 21 18 20 LD HL,$2018 ; Active player descriptor
0372: CD 3B 1A CALL ReadDesc ; Load 5 byte sprite descriptor in order: EDLHB
0375: CD 47 1A CALL ConvToScr ; Convert HL to screen coordinates
0378: CD 39 14 CALL DrawSimpSprite ; Draw player
037B: 3E 00 LD A,$00 ; Clear the task timer. Nobody changes this but it could have ...
037D: 32 12 20 LD (obj0TimerExtra),A ; ... been speed set for the player with a value other than 0 (not XORA)
0380: C9 RET ; Out
ReadDesc is code that reads data from the object descriptor, bytes starting from the address stored in register-HL. As the description suggests, it reads 5 bytes from the descriptor table, and sticks them into the E, D, L, H, B registers. The important bit in particular is that at the end, register HL gives the pixel based location.
Then the ConvToScr is called, which translates the pixel based location to byte based screen coordinates that the hardware understands, then sticks that into register HL.
Then DrawSimpSprite is called, to actually display the sprite image on screen. This goes through the image texture data, byte-by-byte by looking up the sprite image data and copuying to the video RAM (points to by HL) and each, row, HL is incremented by 32 to move to the next scanline.
Moving the player
The code to move the player left and right is below.
MovePlayerRight:
; Handle player moving right
0381: 78 LD A,B ; Player coordinate
0382: FE D9 CP $D9 ; At right edge?
0384: CA 6F 03 JP Z,$036F ; Yes ... ignore this
0387: 3C INC A ; Bump X coordinate
0388: 32 1B 20 LD (playerXr),A ; New X coordinate
038B: C3 6F 03 JP $036F ; Draw player and out
MovePlayerLeft:
; Handle player moving left
038E: 78 LD A,B ; Player coordinate
038F: FE 30 CP $30 ; At left edge
0391: CA 6F 03 JP Z,$036F ; Yes ... ignore this
0394: 3D DEC A ; Bump X coordinate
0395: 32 1B 20 LD (playerXr),A ; New X coordinate
0398: C3 6F 03 JP $036F ; Draw player and out
The "MovePlayerRight" first loads the content of register-B (which holds the X-coordinate) into register-A. It checks if it is at the right edge (by comparing against $D9 or 217 or 11011001). Given that the width of the screen (rotated) is 224 pixels, 217 is 7 pixels from the right. I don't quite understand why it is 217 as opposed to 208, since the width of the player sprite is 16 pixels - perhaps the "origin" of the player sprite is at the centre of the sprite as far as the x-coordinate goes (since each row is 2 bytes). If not yet at the right edge, increases the X-coordinate by 1. In Javascript, I guess INC A is equivalent A++;.
In the case of "MovePlayerLeft", the only difference is that the screen edge check is against $30 (=48 or 00110000), and of course it is "DEC A" as opposed to "INC A". I definitely do not understand why the left edge is 48.
Anyway, as far as moving left and right is concerned, I have replicated this by including a code to set the velocity vector of the Phaser game object at 60 (since there are 60 refresh every second), which seems to be the common way of moving player objects around the screen judging by Phaser's examples. The other way to do this is to set the x coordinate directly by adding delta*speed to the x-object every refresh. The latter approach appears to be more common in games written in other languages.
Edge detection
As I said above, I don't understand why the x-coordinates are compared against 217 and 48 for the edge-check, since the screen width is 224 pixels and sprite width is only 16 pixels. I would be grateful if someone can help me out here.
Anyway, in my version of the code, I have simply used Phaser's built-in collider world bound functionality so the player sprite can go right against the edge of the screen.
Shoot bullet
The code to shoot the player shot sits in the main "game loop" at $1618. This is a bunch of code that reads the fire button, check if there is already a player shot on screen, and set the status of the player shot object to 01, which in turn triggers the player shot object to take action by another bunch of code, which will be covered in more detail in my post for the player shot object, but as a taster, the following is a small extract of the relevant code.
03C7: FE 01 CP $01 ; Shot just starting (requested elsewhere)?
03C9: CA FA 03 JP Z,InitPlyShot ; Yes ... go initiate shot
"InitPlyShot" reproduced below sets the status flag to 2 (by incrementing the previously set flag) to indicate the player shot is flying. Then the position of the bullet is set by adding 8 to the X-coordinate of the player (hence position the bullet to the middle).
InitPlyShot:
03FA: 3C INC A ; Type is now ...
03FB: 77 LD (HL),A ; ... 2 (in progress)
03FC: 3A 1B 20 LD A,(playerXr) ; Players Y coordinate
03FF: C6 08 ADD A,$08 ; To center of player
0401: 32 2A 20 LD (obj1CoorXr),A ; Shot's Y coordinate
0404: CD 30 04 CALL ReadPlyShot ; Read 5 byte structure
0407: C3 00 14 JP DrawShiftedSprite ; Draw sprite and out
Handling Player Explosion
Of the player explosion, CA says:
The player task code is straight forward. If the player is blowing up then the code flips back and forth between two images for half a second.
The actual code to do this is as follows.
; Handle blowing up player
0296: 23 INC HL ; Point to blow-up delay count
0297: 35 DEC (HL) ; Decrement the blow-up delay
0298: C0 RET NZ ; Not time for a new blow-up sprite ... out
0299: 47 LD B,A ; Hold sprite image number
029A: AF XOR A ; 0
029B: 32 68 20 LD (playerOK),A ; Player is NOT OK ... player is blowing up
029E: 32 69 20 LD (enableAlienFire),A ; Alien fire is disabled
02A1: 3E 30 LD A,$30 ; Reset count ...
02A3: 32 6A 20 LD (alienFireDelay),A ; ... till alien shots are enabled
02A6: 78 LD A,B ; Restore sprite image number (used if we go to 39B)
02A7: 36 05 LD (HL),$05 ; Reload time between blow-up changes
02A9: 23 INC HL ; Point to number of blow-up changes
02AA: 35 DEC (HL) ; Count down blow-up changes
02AB: C2 9B 03 JP NZ,DrawPlayerDie ; Still blowing up ... go draw next sprite
The first line of the code increments register HL - the pointer to the object descriptor - by one, so that it points to the explosion animation counter. The second line immediately decrements the counter by 1.
The blue lines sets the playerOK flag to 0 (indicates the player is not OK), and disable ability of aliens to drop bombs by settling the enableAlienFire flag also to zero. The counter for re-enabling this ability to drop bombs is controlled by setting a counter "alienFireDelay" to $30 (= 48) which would imply that aliens are deprived of their ability to drop bombs for 48 refresh cycles - which is half 8 / 10 of a second.
The explosion animation of the player is controlled by 2 counters; one counter set to 5 to switch between the 2 frames, and another counter to control the number of "changes", which is set initially at $0C = 12 (according to the descriptor table in the ROM).
To actually display the explosion image, the following code is called, from the above.
DrawPlayerDie:
; Toggle the player's blowing-up sprite between two pictures and draw it
039B: 3C INC A ; Toggle blowing-up ...
039C: E6 01 AND $01 ; ... player sprite (0,1,0,1)
039E: 32 15 20 LD (playerAlive),A ; Hold current state
03A1: 07 RLCA ; *2
03A2: 07 RLCA ; *4
03A3: 07 RLCA ; *8
03A4: 07 RLCA ; *16
03A5: 21 70 1C LD HL,$1C70 ; Base blow-up sprite location
03A8: 85 ADD A,L ; Offset sprite ...
03A9: 6F LD L,A ; ... pointer
03AA: 22 18 20 LD (plyrSprPicL),HL ; New blow-up sprite picture
03AD: C3 6F 03 JP $036F ; Draw new blow-up sprite and out
03B0: C2 4A 03 JP NZ,$034A ; Alien shots enabled ... move player's ship, draw it, and out
03B3: 23 INC HL ; To 206A
03B4: 35 DEC (HL) ; Time until aliens can fire
03B5: C2 4A 03 JP NZ,$034A ; Not time to enable ... move player's ship, draw it, and out
03B8: C3 46 03 JP $0346 ; Enable alien fire ... move player's ship, draw it, and out
As mentioned above, there are 2 frames each comprising of 16 bytes of data in the explosion animation, stored at $1C70 and $1C80. The frames are flipped by using register-A to point to 16 bytes forward from $1C70 as appropriate. Interestingly, adding 16 is achieved by shifting register-A containing 1 (ie 00000001) 4 times (become 00010000, which is 16!). With HL pointing to the correct frame texture, the plyrSprPicL code is called to draw the image at the same location as the player.
When the explosion sequence is finished
When the explosion sequence is completed, a piece of code referred to as "Blow up finished" (by CA) is called. The first few lines are reproduced below.
; Blow up finished
02AE: 2A 1A 20 LD HL,(playerYr) ; Player's coordinates
02B1: 06 10 LD B,$10 ; 16 Bytes
02B3: CD 24 14 CALL EraseSimpleSprite ; Erase simple sprite (the player)
02B6: 21 10 20 LD HL,$2010 ; Restore player ...
02B9: 11 10 1B LD DE,$1B10 ; ... structure ...
02BC: 06 10 LD B,$10 ; ... from ...
02BE: CD 32 1A CALL BlockCopy ; ... ROM mirror
02C1: 06 00 LD B,$00 ; Turn off ...
02C3: CD DC 19 CALL SoundBits3Off ; ... all sounds
02C6: 3A 6D 20 LD A,(invaded) ; Has rack reached ...
02C9: A7 AND A ; ... the bottom of the screen?
The interesting piece of code are highligted in BLUE. First register HL is loaded with $2010, which is the player object descriptor. Then register DE is loaded with $1B10, register B loaded with $10 (=16) and BlockCopy - the code below - is called. What this does, is copy the 16 bytes (counter indicated by B) object descriptor in ROM (pointed to by register) to RAM (pointed to by HL).
BlockCopy:
; Copy from [DE] to [HL] (b bytes)
1A32: 1A LD A,(DE) ; Copy from [DE] to ...
1A33: 77 LD (HL),A ; ... [HL]
1A34: 23 INC HL ; Next destination
1A35: 13 INC DE ; Next source
1A36: 05 DEC B ; Count in B
1A37: C2 32 1A JP NZ,BlockCopy ; Do all
1A3A: C9 RET ; Done
In fact, at the beginning of a game, the following code is called to "reset" the descriptors of all the game objects, by copying the ROM data from $1B00.
CopyRAMMirror:
; Block copy ROM mirror 1B00-1BBF to initialize RAM at 2000-20BF.
;
01E4: 06 C0 LD B,$C0 ; Number of bytes
01E6: 11 00 1B LD DE,$1B00 ; RAM mirror in ROM
01E9: 21 00 20 LD HL,$2000 ; Start of RAM
01EC: C3 32 1A JP BlockCopy ; Copy [DE]->[HL] and return
Anyway, having gone through all of that, what I do not understand is the duration of the player animation. The timer settings would suggest that player explosion animation runs for 5x12 = 60, ie 1 second. This is not what CA says, so I would be grateful if someone could tell me what I am getting wrong. In anycase, all this counting is not necessary when using Phaser's animation function.
Javascript/Phaser 3 version
Armed with the above understanding, I have implemented the Javascript / Phaser 3 player object as follows.
class Player extends Phaser.Physics.Arcade.Sprite {
constructor(scene, x, y) {
super(scene, x, y, 'player');
this.scene = scene;
scene.add.existing(this);
scene.physics.add.existing(this);
// set body's collider world bounds to true so that the player cannot be moved outside the screen
this.body.collideWorldBounds = true;
this.status;
this.score;
this.extendLifeCount;
this.lives;
this.shotsCount; // incremented each time play fires shot. used to determined direction of flying saucer
this.left = scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT);
this.right = scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT);
this.spaceBar = scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.setStatus(Player.Status.INIT);
} // end of constructor
static Status = {
INIT: 0,
STANDBY: 1,
NORMAL: 2,
EXPLODE: 3
}
static PLAYER_Y = 228;
static PLAYER_X = 54;
static MOVESPEED = 60;
static INITIAL_LIVES = 3;
static EXTEND_LIFE_TABLE = [1000, 1500, Infinity];
preUpdate(time, delta) {
super.preUpdate(time, delta);
switch (this.status) {
case Player.Status.NORMAL:
this.handleInput();
break;
case Player.Status.EXPLODE:
if (!this.anims.isPlaying) {
this.setStatus(Player.Status.STANDBY);
}
default:
break;
}
}
setStatus(newStatus) {
this.status = newStatus;
switch (newStatus) {
case Player.Status.INIT:
this.lives = Player.INITIAL_LIVES;
this.extendLifeCount = 0;
this.score = 0;
this.shotsCount = 0;
this.setStatus(Player.Status.STANDBY);
break;
case Player.Status.STANDBY:
this.disableBody(true,true);
break;
case Player.Status.NORMAL:
this.enableBody(true, Player.PLAYER_X, Player.PLAYER_Y, true, true);
this.setTexture('player');
break;
case Player.Status.EXPLODE:
this.body.stop();
this.lives --;
this.play('playerExplode');
break;
}
}
handleInput() {
if (this.left.isDown) {
this.body.velocity.x = -Player.MOVESPEED;
} else if (this.right.isDown) {
this.body.velocity.x = Player.MOVESPEED;
} else {
this.body.velocity.x = 0;
}
if (Phaser.Input.Keyboard.JustDown(this.spaceBar)) {
this.shoot();
} // end of if statement which executes the shoot function if the space bar is being pressed
} // end of move method
incrementScore(points) {
this.score += points;
if (this.score > Player.EXTEND_LIFE_TABLE[this.extendLifeCount]) {
this.extendLifeCount ++;
this.lives ++;
this.scene.extendLife();
}
}
shoot() {
if (this.scene.bullet.status === Bullet.Status.STANDBY) {
this.shotsCount ++;
this.scene.shootSFX.play();
this.scene.bullet.setStatus(Bullet.Status.FIRED);
} // end of if statement which checks whether bullet is null
} // end of shoot method
} // end of Player class
In the next post, I go on to how I recreated the Player's shot.
コメント