As another step up from the game of snake I created in my last post I decided to try my hand at creating a side scrolling shooter. Just like my other posts, this is an ASCII based game played on the command line.
A side scrolling shooter, if you didn't already know, moves a scene from right to left across the screen with enemies moving with the scene towards the player's ship, which is on the left hand side of the scene. The player can fire bullets towards the enemies so remove them from the scene.
In order to create a side scrolling shooter we need to define a few elements.
- The player's ship.
- The enemies for the player to avoid.
- The bullets that the player's ship shoots.
All of these items will be positioned in the scene using x,y coordinates so to simplify things a base class of Entity can be created. This will encapsulate the position of each of the entities in the scene.
class Entity {
public $positionX = 0;
public $positionY = 0;
public function __construct($x, $y) {
$this->positionX = $x;
$this->positionY = $y;
}
}
By extending this base Entity class we can create the components needed for the game. The Spaceship needs a couple more properties to allow it to move around the scene and to fire towards the enemies.
class Spaceship extends Entity {
public $movementX = 0;
public $movementY = 0;
public $fire = FALSE;
}
class Enemy extends Entity {}
class Bullet extends Entity {}
The main game is controlled via a single Scene class. This class stores all of the components needed for the game and allows the game to be controlled by the player. The constructor of the class stores the dimensions of the game scene and then creates the player's ship object. The ship is placed in the middle height of the scene 2 squares from the left hand side.
class Scene {
public $height;
public $width;
public $ship;
public $enemies = [];
public $bullets = [];
public $score = 0;
public function __construct($width, $height) {
$this->width = $width;
$this->height = $height;
$this->ship = new Spaceship(2, round($this->height / 2));
}
}
In order to move the ship around a moveShip() method is used. This will take the current movement x,y and apply it to the position properties of the ship. Some logic is applied to the movements to prevent the player from going beyond the bounds of the scene and to stop the ship when it reaches the half way point.
public function moveShip() {
$this->ship->positionX += $this->ship->movementX;
$this->ship->positionY += $this->ship->movementY;
$this->ship->movementX = 0;
$this->ship->movementY = 0;
if ($this->ship->positionX < 0) {
$this->ship->positionX = 0;
}
if ($this->ship->positionX >= $this->height) {
$this->ship->positionX = $this->height - 1;
}
if ($this->ship->positionY < 0) {
$this->ship->positionY = 0;
}
if ($this->ship->positionY > $this->width / 4) {
$this->ship->positionY = $this->width / 4;
}
}
As well as moving the ship we also want the ship to shoot bullets. When the ship is in 'fire' mode a new bullet object is created, using the position that the ship is in as a basis for the position of the bullet. Once a bullet has been fired we turn off the fire state for the ship, which means that we only fire one bullet at a time.
public function shoot() {
if ($this->ship->fire == TRUE) {
$this->bullets[] = new Bullet($this->ship->positionX, $this->ship->positionY + 1);
$this->ship->fire = FALSE;
}
}
With the ship movement and shoot methods in place we need to listen to the player's input and apply those actions to the ship. This uses the same key detection logic that we used in the previous posts, including the buttons to fire bullets and a the Esc button to quit the game.
public function action($stdin) {
// Listen to the button being pressed.
$key = fgets($stdin);
if ($key) {
$key = $this->translateKeypress($key);
switch ($key) {
case "UP":
$this->ship->movementX = -1;
$this->ship->movementY = 0;
break;
case "DOWN":
$this->ship->movementX = 1;
$this->ship->movementY = 0;
break;
case "RIGHT":
$this->ship->movementX = 0;
$this->ship->movementY = 1;
break;
case "LEFT":
$this->ship->movementX = 0;
$this->ship->movementY = -1;
break;
case "ENTER":
case "SPACE":
$this->ship->fire = TRUE;
break;
case "ESC":
die();
}
}
}
private function translateKeypress($string) {
switch ($string) {
case "\033[A":
return "UP";
case "\033[B":
return "DOWN";
case "\033[C":
return "RIGHT";
case "\033[D":
return "LEFT";
case "\n":
return "ENTER";
case " ":
return "SPACE";
case "\e":
return "ESC";
}
return $string;
}
Moving the bullets across the scene is quite simple, we just increment the y position of each bullet in the scene. This moves the bullets in a horizontal manner towards the right hand side of the scene.
public function moveBullets() {
foreach ($this->bullets as $bullet) {
$bullet->positionY++;
}
}
We need something for the player to shoot at so let's create a method to spawn enemies. This function will ensure that a minimum of 15 enemies are on the screen at any one time. We call this method every time we move the scene so if the player shoots one of the enemies then another will spawn in. The spawning of enemies will be done at a random position outside of the right hand side of game 'screen' so that they are decently placed before they are moved into the scene.
public function spawnEnemies() {
if (count($this->enemies) < 15) {
$y = rand($this->width, $this->width * 2);
$x = rand(0, $this->height - 1);
$this->enemies[] = new Enemy($x, $y);
}
}
Moving the enemies in the scene is slightly move involved as we also need to detect if the enemy needs to be removed. If and enemy has gone beyond the left hand side of the scene then it can be removed from the scene without incident. If the enemy is in the same spot as a bullet then both are removed and the player receives a +1 to their score. This just takes a little bit of position matching to ensure that the enemies are in the right place before removing them.
public function moveEnemies() {
foreach ($this->enemies as $enemyId => $enemy) {
$enemy->positionY--;
if ($enemy->positionY == 0) {
// Remove the enemy if it goes beyond the left hand side of the scene.
unset($this->enemies[$enemyId]);
continue;
}
foreach ($this->bullets as $bulletId => $bullet) {
if ($bullet->positionX == $enemy->positionX && ($bullet->positionY == $enemy->positionY || $bullet->positionY == $enemy->positionY - 1)) {
unset($this->enemies[$enemyId]);
unset($this->bullets[$bulletId]);
$this->score++;
}
}
}
}
The final step here is to add a 'game over' scenario. This happens if an enemy reaches the same x,y position of the player's ship. We just kill the entire program here and print the game over message to the output.
public function gameOver() {
foreach ($this->enemies as $enemy) {
if ($this->ship->positionX == $enemy->positionX && $this->ship->positionY == $enemy->positionY) {
die('dead :(');
}
}
}
With all of those elements in place the game scene can now be rendered. This rendering method loops through the height and width of the scene and prints out each of the elements within the scene. I've treated each x,y coordinate here as a cell in a grid to make things easier to understand. The ship is represented as a greater than symbol, the enemies by the letter x and the bullets as a dash. Everything else on the scene is blank.
public function renderGame() {
$output = '';
for ($i = 0; $i < $this->height; $i++) {
for ($j = 0; $j < $this->width; $j++) {
if ($this->ship->positionX == $i && $this->ship->positionY == $j) {
$cell = '>';
}
else {
$cell = ' ';
}
foreach ($this->enemies as $enemy) {
if ($enemy->positionX == $i && $enemy->positionY == $j) {
$cell = 'X';
}
}
foreach ($this->bullets as $bullet) {
if ($bullet->positionX == $i && $bullet->positionY == $j) {
$cell = '-';
}
}
$output .= $cell;
}
$output .= PHP_EOL;
}
$output .= PHP_EOL;
$output .= 'Score:' . $this->score . PHP_EOL;
return $output;
}
To run the game we just need to instantiate the Scene object with a given height and width, set up the stream listening system to detect the user's input and then run the game loop. The game loop is an infinite loop that will call the methods I have outlined above over and over again (at least until the game ends). The order of the methods is important here as we need to listen to the user's action before moving and shooting the ship. After this we call the methods to move the bullets and enemies in the scene.
$scene = new Scene(50, 10);
system('stty cbreak -echo');
$stdin = fopen('php://stdin', 'r');
stream_set_blocking($stdin, 0);
while (1) {
system('clear');
$scene->action($stdin);
$scene->moveShip();
$scene->shoot();
$scene->moveEnemies();
$scene->moveBullets();
echo $scene->renderGame();
$scene->gameOver();
$scene->spawnEnemies();
usleep(100000);
}
Running this code displays the scene with the player's ship in it. After a couple of seconds enemies start to appear and we can shoot at them using the space bar. If a bullet reaches an enemy then the enemy is removed and we get a +1 to the score.
- X X
X
> - X X X
X X
X X
X
X
Score:2
I have also created a gif of the game in action.
via GIPHY
As an addition to this it is also possible to add a call to srand() to set a seed for the random values that the enemies are created with. By adding a seed to the randomness we can set spawning of the enemies to be more predictable every time the game is played. It's not entirely predictable as the player removing enemies from the scene will change how more enemies are spawned in, but the initial condition of the game are always the same. I've written about using random seeds in PHP before if you want to know more. This function just needs to be placed at the start of the script.
srand(1);
There is a lot of code here, but this works really well. In fact, I'm actually surprised how well it works given that it's written in PHP and is running only on the command line. It's no R-Type or Silkworm, but for a simple script I'm very pleased. The scene in the gif above is only 50 wide and 10 high, but I have had it working well with 150 wide and 30 high. It should even be possible to set a level of difficulty by increasing the number of enemies spawned onto the scene at any given moment. I have added the whole side scrolling shooter PHP script to a gist if you want to take a look. Feel free to give it a go and let me know your high scores!
Add new comment