SpaceInvaders
By Erick Engelke
January 13, 2025
My daughter recently wrote a PONG game in Python, and so I challenged her to write a Space Invaders clone as an exercise.
I decided to make a simple EWB based version from scratch. The sample can be found at: SpaceInvaders and the souce can be downloaded at Zip file.
The rest of this blog post describes the basic code.
Where to start… I found a PNG file with a bunch of Space Invader aliens, so I load that into memory and split it up into different alien sprites.
So, we have image1, which is a TImage with the URL set to that image, and its OnLoad handler cuts it into 9 images .
procedure TForm1.Image1Load(Sender: TObject);
var
i, row, col : integer;
x, y : integer;
image : TImage2;
begin
image := TImage2.Create( self );
image.parent = self;
image.url := image1.URL;
image.visible := false;
i := 0;
setlength( icons, 9 ); // there are 9 icons in the image
for row := 0 to 2 do begin
for col := 0 to 2 do begin
x := col * 400;
y := row * 400;
icons[ i] := CopySubImage( image, y, x, 399, 399 );
inc( i );
end;
end;
end;
Okay, TImage2 type is an enhanced TImage type which is a cracker class that exports ImageELement which is normally hidden from other code. ImageElement is needed so we can use DrawImage in the code which separates it into smaller images.
// cracker class
type
TImage2 = class( TImage )
public
property imageelement : TImageElement read fImageElement;
end;
function TForm1.CopySubImage( img : TImage2 ; row, col, xheight, xwidth : integer ):TImage;
var
paint : TPaint;
begin
result := TImage.Create( form1 );
result.parent := form1;
result.visible := False;
result.width := xwidth;
result.height := xheight;
paint := TPaint.Create( form1 );
paint.Width := xwidth;
paint.Height := xheight;
paint.visible := False;
paint.Canvas.DrawImage( img.imageelement, col, row, xwidth, xheight , 0, 0, paint.width, paint.height );
result.url := paint.canvas.ConvertToDataURL('png', 100 );
end;
Now that we have separated the Aliens into images, we need to draw them as sprites on the screen.
DrawSpriteAt lets you assign or reuse a sprite (number spritenum), using icon number inconnum, at the location (x,y).
The first bit of code sees if we have assigned that sprite, if not, it makes the new sprite.
Then the common code just places it wherever you want on the screen.
// Show the sprites where wanted
procedure TForm1.DrawSpriteAt( spritenum, iconnum, x, y : integer);
var
spr : TImage;
begin
if not Assigned( sprites[ spritenum ] ) then begin
spr: = TImage.Create( form1 );
spr.width := width div 25; // resize the sprite
spr.height := spr.width;
spritewidth := spr.width;
spr.parent := form1;
sprites[ spritenum ] := spr;
spr.url := icons[ iconnum ].url;
spr.border.left.visible := False;
spr.border.right.visible := False;
spr.border.top.visible := False;
spr.border.bottom.visible := False;
spr.contentlayout.size := cscontain;
end;
spr := sprites[ spritenum ];
spr.Top := y;
spr.Left := x;
end;
We also need to make walls above the shooter to protect him from aliens. The walls block bullets, but are eaten away upon being hit by a bullet.
We define a wall element as a TLabel with a single coloured X, and we hide that element once it is struck by a bullet.
procedure TForm1.MakeWalls;
var
i, j : integer;
wall : TLabel;
begin
//
setlength( walls, 50 );
for i := 0 to 10 do begin
for j := 0 to 5 do begin
wall := TLabel.Create( self );
wall.parent := self;
wall.Background.fill.color := clgreen;
wall.Top := player.top - 20;
wall.caption := 'X';
wall.left := i*(width div 10 ) + wall.width*j;
walls[i*10 + j] := wall;
end;
end;
end;
When the user clicks the start button, we call StartGame.
// draw initial sprites
procedure TForm1.Startgame;
var
spr, row, col : integer;
begin
MakeWalls;
// define and place the sprites in their location
spr := 0;
setlength( sprites, 50 );
form1.BeginUpdate;
for row := 0 to 5 do begin
for col := 0 to 10 do begin
DrawSpriteAt( spr, row, col * spritewidth*2, row * spritewidth );
inc( spr );
end;
end;
Form1.EndUpdate;
// make an array of bullets which will travel, they
setlength( bullets, 10 );
// set the default direction to +1, or right
// later we will switch to -1 or left
spritedirection := 1;
// update the positions every 10 ms
timer1.Interval := 10;
timer1.Enabled := True;
// speed is the number of pixels every moves ever 10 ms
speed := 4;
// at level 1 bullets per interval is 1, high levels
// add more bullets per interval
bulletspersecond := 1;
// note the start time, we will up the level after
// a period of time, making it harder
starttime := now;
points := 0;
end;
Timer1 is the workhorse, it does all the animation.
procedure TForm1.Timer1Timer(Sender: TObject); var i : integer; spr : TImage; nextspritedirection : integer; bullet : TBullet; secondsactive :integer; begin updatelable;
secondsactive := ( Integer(now) - Integer(starttime )) div 1000; if secondsactive > 30 then begin inc( bulletspersecond ); starttime := now; TimerFire.interval := TimerFire.Interval - 250; updatelable; end;
nextspritedirection := spritedirection; beginupdate; for i := 0 to length( sprites ) do begin if assigned( sprites[i] ) and sprites[i].visible then begin spr := sprites[i]; spr.left := spr.left + 5 * spritedirection; if spr.left > width - spritewidth then nextspritedirection := -1; if spr.left < spritewidth then nextspritedirection := 1; end; end;
// update bullets for i := 0 to length( bullets ) do begin if assigned( bullets[i] ) then begin bullet := bullets[i]; // only process if the bullet // is visible, otherwise it's inactive if bullet.visible then begin // bullets in either direction can hit walls testbullethitwall( bullet );
// did that change it? if not bullet.visible then continue;
if bullet.directiondown then begin bullet.Top := bullet.Top + speed; // did it hit testbulletHitPlayer( bullet ); // did it go off the bottom of screen // into oblivion if bullet.top > height then bullet.visible := False; end else begin bullet.Top := bullet.Top - speed; // did it hit testbulletHitAlien( bullet ); // did it go off top of screen into oblivion if bullet.top < 1 then bullet.visible := false; end; end; end; end;
endupdate;
spritedirection := nextspritedirection; end;
Now we need a way to start bullets, either downward from the aliens, or upward from the shooter/player.
procedure TForm1.FireFrom( img : TControl; downward : boolean );
var
bullet : TBullet;
i : integer;
begin
bullet := TBullet.Create( form1 );
bullet.parent := form1;
// find a spare bullet in the array, 50 is more
// than enough
for i : = 0 to length( bullets ) do begin
if not assigned( bullets[i] ) or not bullets[i].visible then begin
bullets[i] := bullet;
break;
end;
end;
bullet.caption := '|';
if downward then
bullet.font.color := clRed
else
bullet.font.color := clBlack;
bullet.visible := True;
bullet.top := img.Top;
bullet.left := img.Left + img.Width div 2 ;
bullet.directiondown := downward;
end;
Our second timer fires a random bullet from a random alien.
procedure TForm1.FireFromRandom;
var
i, rnd : integer;
spr : TImage;
cnt : integer;
begin
// we see how many bullets per interval, by default
// it just is one.
for cnt := 1 to bulletspersecond do begin
// we cycle through aliens, looking for one which
// is still visible and active. Dead aliens can't
// fire!!!
for i := 0 to 50 do begin
rnd := random( 0, length( sprites ));
if Assigned( sprites[rnd] ) then begin
spr := sprites[rnd];
if spr.visible then begin
firefrom( spr, True );
break;
end;
end;
end;
end;
end;
procedure TForm1.TimerFireTimer(Sender: TObject);
begin
firefromrandom;
end;
We need to test bullets to see if they hit a wall.
procedure TForm1.TestBulletHitWall( bullet : TBullet );
var
wall : TLabel;
i : integer;
begin
for i := 0 to length( walls ) do begin
wall := walls[i];
if assigned( wall ) and wall.visible then begin
if not ((bullet.top >= wall.top )
and ( bullet.top <= wall.top+wall.height )) then
exit; // they are all at the same Top
if (bullet.left >= wall.left) and
( bullet.left <= wall.left+ wall.width ) then begin
bullet.visible := false;
wall.visible := false;
end;
end;
end;
end;
The procedures to test if the bullet hit any Alien or the Player are similar
procedure TForm1.TestBulletHitPlayer( bullet : TBullet );
begin
//
if ( bullet.top + bullet.height >= player.Top) and ( bullet.top < player.top + player.height) then begin
if ( bullet.left >= player.left) and (Bullet.left <= (player.left + player.width )) then begin
timer1.Enabled := False;
TimerFire.Enabled := False;
showmessage( 'You''re hit');
end;
end;
end;
procedure TForm1.TestBulletHitAlien( bullet : TBullet );
var
i : integer;
spr : TImage;
begin
//
for i := 0 to length( sprites ) do begin
if assigned( sprites[i] ) then begin
spr := sprites[i];
if spr.visible then begin
if ( bullet.top >= spr.top ) and ( bullet.top <= spr.top + spr.height )
and ( bullet.left >= spr.left ) and ( bullet.left <= spr.left + spr.width ) then begin
spr.visible := false;
bullet.visible := false; // cancel the bullet so it doesn't strike more than one
end;
end;
end;
end;
end;
How do we handle mouse movement?[source,]
procedure TForm1.Form1MouseMove(Sender: TObject; ShiftKey: Boolean; CtrlKey: Boolean; AltKey: Boolean; X: Integer; Y: Integer); begin player.left := x; end;
That was simple. We also catch the MouseDown and the Keystroke event handlers to fire.
function TForm1.Form1KeyPress(Sender: TObject; Key: Char; ShiftKey: Boolean;
CtrlKey: Boolean; AltKey: Boolean): Boolean;
begin
firefrom( player, False );
end;
procedure TForm1.Button1Click(Sender: TObject);
begin
button1.Visible := False;
startgame;
end;
procedure TForm1.Form1MouseDown(Sender: TObject; Button: Integer;
ShiftKey: Boolean; CtrlKey: Boolean;
AltKey: Boolean; X: Integer; Y: Integer);
begin
firefrom( player, false );
end;
I’ve skipped a couple of obvious functions, but that, in a nutshell, is the game.
Late Breaking News…
I’ve just added to more calls to support iPad’s and certain other touch-capable devices.
procedure TForm1.Form1TouchMove(Sender: TObject; ShiftKey: Boolean;
CtrlKey: Boolean; AltKey: Boolean;
X: Integer; Y: Integer);
begin
player.left := x;
end;
procedure TForm1.Form1TouchEnd(Sender: TObject; ShiftKey: Boolean;
CtrlKey: Boolean; AltKey: Boolean; X: Integer;
Y: Integer);
begin
firefrom( player, false );
end;