-
-
Notifications
You must be signed in to change notification settings - Fork 0
Home
SpeccyEngine is a simple game engine inspired by the Sinclair Basic programming syntax used on the Sinclair ZX Spectrum computer from the 1980s. You can use this engine to create games (and "non-game" for that matter) that are visually reminiscent of ZX Spectrum programs.
The screen is a 60x40 character (480x320 pixel) screen, where you can "print" a string - either ASCII or custom glyphs (aka "user defined graphics" if you are familiar with ZX Spectrum programming). Strings can have a foreground and background colour, and attributes such as "flash" or "invert". A handful of basic shapes can also be drawn, such as lines and ellipses.
The game engine's "game loop" runs at 4fps by default, but this is configurable (depending on the style of game you are writing, and how "nostalgic" you feel). I have found it stable up to around 18fps.
Download the source and open it in Visual Studio. Set the SpecCoreLib.Demo project is set as the startup project. The solution includes a few basic sample games to try: edit the MainWindow.xaml.cs code-behind and uncomment just one of the "new..." lines in the constructor. Make sure the others remain commented out otherwise you'll probably end up with a white screen. Each of these lines launches a separate game:
- MazeGame - a simple maze puzzle
- SpaceRaiders - shoot through the force field to destroy the mother ship while avoiding its bombs
- Sabotage - plant a bomb in the warehouse to cause maximum damage, while avoiding the security guards
- TestCard - demonstrates a few basic capabilities (follow the on-screen prompts)
To write your own program, create a WPF Core project and add a class that inherits from SpeccyEngine (this type can be found in the "SpecCoreLib" project). Implement the required constructor, and override the Init() and DoFrame() methods. The class should look something like this:-
public class HelloWorld : SpeccyEngine
{
public HelloWorld(Window window)
: base(window)
{
}
protected override void Init()
{
}
protected override async void DoFrame()
{
Print(10, 27, "Hello!");
}
}
Note the "async" keyword on DoFrame().
All that remains is to wire this up to the UI: open the main window code-behind (MainWindow.xaml.cs) and add the following line to the constructor, before InitializeComponent():-
new HelloWorld(this);
Compile and run the project and you should see your message in the centre of the window. The next step is to make this do something much more interesting! This involves writing C# code, and utilising methods and properties exposed by the SpeccyEngine base class, for displaying graphics, capturing keypresses, and so on. Read on to discover more...
This is where you initialise your game or program before it starts. Init() runs once, and once only, at startup. You'll rarely use this to be honest - most of the time you could use the constructor to initialise your variables. Init() does provide access to the UI if you need it, but again this isn't all that useful. While you could use it to display a "welcome" screen, you might want to return to this when the game ends, which wouldn't be possible (as Init() isn't called again). You're more likely to use DoFrame(), and implement some kind of "state" enum to control which screens to display (welcome, in-game, game over, etc).
This method is called four times per second (by default), and is where you would "render" one frame of your game. The low frame rate gives you an experience similar to the kind of games you would spend hours typing in from "ZX Spectrum Magazine", but this can be increased as mentioned earlier. How to do this is described later.
Think of DoFrame() as the place where you move an enemy spaceship one unit to the left, or move the player's character if a cursor key is being pressed. It's important to understand that this method "blocks", so any graphical operations you perform in here won't appear on-screen until DoFrame() exits, giving the UI thread a chance to refresh the screen. You should also ensure that you aren't doing too much in DoFrame(). If the frame rate is 4ps then that gives you a maximum of 250ms to do everything that you need to. Don't worry - I've found this to be plenty. The worst that will happen is that your game will slow down if you try to do to much per frame. Sometimes you can't help but stuff a lot into a single frame, for example when initialising the start of a game. Take the "maze" program included in the SpecCoreLib.Demo project. This takes a few seconds to generate a maze, but this isn't an issue as it only happens once when the game starts, and not on every frame.
Take a look at this code:-
public override async DoFrame()
{
for (var col = 0; col < 20; col++)
{
Print(0, col, "*");
Thread.Sleep(100);
}
}
Now, you might expect to see a line of asterisks appear across the screen, one every 100 milliseconds, but that's not what will happen. As mentioned earlier, DoFrame() blocks the UI thread, and the screen will only update when the method exits, at which point you'll see the line of all twenty asterisks appear at once. Additionally, that Thread.Sleep() will just slow things right down, resulting in each frame taking two seconds.
Incidentally, the correct way to achieve the line of asterisks would be something like this:-
private int _col;
...
public override async DoFrame()
{
Print(0, _col, "*");
_col++;
}
(This will also run slower than the previous snippet, as the default frame rate is 4fps). There is an alternative way of creating these "embedded" animations, and will be covered later.
If you know C# then there isn't much to SpeccyEngine at all. I suggest you read through the following section to learn the methods and properties that the library provides.
The Clear() method accepts a System.Windows.Media.Color type and will clear entire screen, setting it to this colour.
The Scroll() method will scroll the entire screen up one row. The top row will effectively disappear off the top of the screen, and the bottom row will be cleared using the current Paper colour.
Produces a beep sound at the given frequency, and for the given duration (in milliseconds).
This sets the number of times that DoFrame() is called per second. It seems to be reliable up to around 18fps. Higher values seem to have little to no effect.
Restores the frame count to the default value of 4.
The following methods and properties allow you to display text and graphics* on the screen, using different colours and effects. The screen consists of 2,400 characters, arranged into 40 rows x 60 columns. *The term "graphics" is used loosely here, and refers to a "user defined graphic" character that gets displayed on the screen, just like any other ASCII character.
Prints the given text at the given row and column, using the current Paper and Pen colours. 'row' must be 0-39 and 'column' must be 0-59. Example:-
Print(0, 0, "Hello");
Will print the word "Hello" in the top-left corner of the screen.
- If the text is too long to fit on the remaining line, it will be truncated at the right-hand edge of the screen. It will not wrap to the next line.
- Printing over existing text will replace that text, however you can achieve an "overlay" effect if the current Paper colour is Colors.Transparent.
This property lets you set the foreground colour, which will be applied to successive calls to Print(), until changed to a new colour. The value is a System.Windows.Media.Color type. E.g.
Pen = Colors.Red;
This property lets you set the background colour, which will be applied to successive calls to Print(), until changed to a new colour. The value is a System.Windows.Media.Color type. E.g.
Paper = Colors.Yellow;
This boolean property lets you set the "flash" mode, which will be applied to successive calls to Print(), until changed to a new value. When true, the text will alternately switch its foreground and background colours, every half a second approx.
Flash = true;
Setting Flash to false does not stop already-printed text from flashing. The updated value will only affect text output by subsequent calls to Print().
Returns information about the character at the given screen position. Often used for "collision detection" during games. 'row' must be 0-39 and 'column' must be 0-59. The TextCell object returned by this method exposes a number of useful properties about the character at that position:-
- Pen - the character's foreground colour
- Paper - the character's background colour
- Flash - whether the character's "flash" attribute is set
- Invert - whether the character's "invert" attribute is set
- Character - the character (text) at that position
- IsGraphicChar - indicates whether or not the character is a "user defined graphic" character. If the queried cell contains the glyph "¬B" (see later section on user defined graphics), then 'Character' will equal "B" and 'IsGraphicChar' will equal true.
Example:-
var info = GetTextCell(_playerRow, _playerCol);
if (info.Character == "*")
...
This method plots a single pixel point on the screen, using the current Pen colour. Example:-
Pen = Colors.Purple;
Plot(150, 83);
This method draws a line between (x1,y1) and (x2,y2), using the current Pen colour.
This method draws a circle with the given radius and centred on (x,y), using the current Pen colour.
This method draws an ellipse with the given radii and centred on (x,y), using the current Pen colour.
- X values must be in the range 0 to 479, and Y values must be in the range 0 to 319 (the screen is 60x40 characters, and each character is made from a grid of 8x8 pixels).
- The Paper, Flash and Invert attributes have no effect on drawing methods
- Lines and points can be drawn on over existing text. However text printed on top of drawn shapes will normally replace those areas of the shape, unless the current paper colour is Colors.Transparent
In a game loop you might want to use the cursor keys to control movement of the player's character. This is the kind of pattern you would use:-
// Vars holding player position, probably initialised somewhere.
int _playerRow, _playerCol;
...
// Remove the player's "character" at its current location
Print(_playerRow, _playerCol, " ");
if (LastKeyPress == Keys.Up && _playerRow > 0)
{
_playerRow--;
}
else if (LastKeyPress == Keys.Down && _playerRow < 39)
{
_playerRow++;
}
...
// Redraw the player's character at its new location
Print(_playerRow, _playerCol, "*");
Let's say you want to ask the user to enter their initials to add to a high score table. This is how you do it (note the 'async' keyword):-
var _userName = await InputAsync(10, 20, 3);
This will display a flashing cursor on the screen at row=10, column=20, limiting the input to a maximum of 3 characters. The user must press Enter to submit the input.
Note that InputAsync() blocks until the user hits Enter. No further "frames" will be rendered while waiting for the user input.
When writing games on the ZX Spectrum using Sinclair Basic, it was possible to use a number of "special characters" - simple shapes and shaded characters that could be used as building blocks for simple graphics. SpeccyEngine provides the same set of special characters, which are printed using a special notation. For example this will print an upward "step" shape:-
Print(0, 0, "¬J");
There are 24 glyphs in total, assigned to the letters A to X, which should be preceded by the "¬" character when printing:
Another concept "borrowed" from Sinclair Basic is that of "user defined graphics", where a programmer can design their own characters resembling spaceships, bullets, and so on. SpeccyEngine lets you do this too! A character is made up of an 8x8 grid of pixels, and this is how you create your own:-
var missile = new[]
{
"00011000",
"00011000",
"00111100",
"00011000",
"00011000",
"00011000",
"00111100",
"01100110",
};
An array of eight strings is created, each containing eight 0s or 1s. This is the pixel design - if you squint really hard, you might just see a missile. To assign this glyph use the following:-
SetGraphicGlyph('a', missile);
The first argument is the character that you want to assign the graphic to, which must be a lowercase alphabetical character (a-z). The second argument is the string array.
Print the custom glyph in the same way as the built-in glyphs, but specifying a lowercase character:-
Print(0, 0, "¬a");
It's debatable whether this actually looks like a missile...
As you saw earlier, DoFrame() is used for animating the game one frame at a time. But what happens when the player's spaceship is hit by a bomb and you want to display a nice explosion animation? Advancing such an animation one frame at a time would quickly become tiresome. This is where the methods RunOutOfFrameAnimationAsync() and ForceFrame() come in.
These methods let you perform a rudimentary animation during one frame. The app will still "block" while this is happening, but unlike the earlier "asterisk line" example, here you can see the screen update during the sequence.
Here's an example:-
public override async void DoFrame()
{
...
if (_playerHitByBomb)
{
await RunOutOfFrameAnimationAsync(() => RenderPlayerExplosion());
}
...
}
Note the use of the "await" keyword. And here's the method that it calls, which does a simple explosion animation by drawing a series of yellow and orange lines of random lengths:
private void RenderPlayerExplosion()
{
var rnd = new Random();
// Get the player's position as x,y pixel coordinates
var x = _playerCol * 8;
var y = _playerRow * 8;
for (var i = 0; i < 100; i++)
{
Pen = i % 2 == 0 ? Colors.Orange : Colors.Yellow;
Line(x, y, x + rnd.Next(100) - 50, y + rnd.Next(100) - 50);
Thread.Sleep(30);
ForceFrame();
}
}
Within the "for" loop you'll notice a call to ForceFrame(), which effectively tells the SpeccyEngine framework to "release" the UI thread, allowing it to update based on what graphical operations have just occurred. Additionally, there is a "Thread.Sleep" to slow things down a little. Without this the animation would complete too quickly.
As mentioned above, this will block the call to DoFrame(), so nothing else will happen during this time. If you want the alien invaders to continue moving while your player's spaceship explodes then you're out of luck. You'd have to do this frame-by-frame in DoFrame().
And that's it! Have fun and post any successes you have on Twitter or Instagram using the #SpeccyEngine tag!
Andy