Implementation
Drawing
The display of the game console can differentiate between four grey scales. Each pixel on the display is defined by two bits (00 ≙ white pixel, 01 ≙ light grey pixel, 10 ≙ dark grey pixel, 11 ≙ black pixel).
To convert PNG images into code we wrote a Python script that takes a PNG image as input and converts it into a C array. The entries of the array are 8-bit entries that represent the different pages (page ≙ four vertical pixels) of the image.
Instead of using the page()
function
we wrote the following function,
in which we use the window functionality of the display.
The details are explained in the data sheet.
void drawsprite(uint8_t x, uint8_t y, uint8_t width, uint8_t height, const uint8_t* sprite)
{
enable_window(x, y, width, height);
for (uint16_t i = 0; i < width * height; ++i)
sendbyte(pgm_read_byte_near(sprite + i), 1);
disable_window();
}
This is faster than using the page()
function, because fewer commands are sent to the display.
In order to draw pixel-by-pixel and not only pagewise in the vertical direction,
we wrote the drawsprite_px()
function:
void drawsprite_px(uint8_t x, uint8_t y, uint8_t width, uint8_t height, const uint8_t* sprite)
{
uint8_t offset = 2 * (y % 4);
if (offset == 0)
{
drawsprite(x, y / 4, width, height / 4, sprite);
}
else
{
enable_window(x, y / 4, width, height / 4 + 1);
uint16_t i = 0;
for (; i < width; ++i)
sendbyte(pgm_read_byte_near(sprite + i) << offset, 1);
for (; i < height / 4 * width; ++i)
sendbyte(pgm_read_byte_near(sprite + i) << offset |
pgm_read_byte_near(sprite + i - width) >> (8 - offset), 1);
for (; i < (height / 4 + 1) * width; ++i)
sendbyte(pgm_read_byte_near(sprite + i - width) >> (8 - offset), 1);
disable_window();
}
}
Problem: Flash Size
Since the Atmega 328 only has 32 KiB of flash memory,
we had to come up with creative ways to reduce the space used.
First, before storing the sprites as arrays in the PROGMEM
,
we wrote the calls to the page()
function by hand for every sprite.
Of course, it is much more efficient to read the pages from an array.
Second, large images tend to consist of a lot of unnecessary white pixels
if the form of the object displayed is not rectangular.
Therefore, we split some sprites up into smaller chunks:
Level Setup
Every level consists of a number of rooms which is a random number between one and five. Furthermore the monster(s) and the positions of the platforms and water are chosen randomly for every new room and the floor and ceiling sprite is chosen randomly for every new level.
To make sure that a level you have already been to looks the same as before, we choose the seed of the random number generator depending on the level and on the room the protagonist is in.
srandom(level_seed + level_pos);
platforms_13 = random();
platforms_19 = random();
platforms_24 = random();
nofloor = random();
Depth-first Search
Since the position of the platforms and the water is chosen randomly, it is possible that the protagonist is not able to simply walk from left to right, Nevertheless, we have to ensure that he can reach the door. Therefore, we implemented an algorithm based on depth-first search that tries to find a path from left to right and also considers jumping on platforms. Below, we will explain the idea behind this algorithm using an exemplary level.
If there is no path, we set the nofloor
variable to a new random value
and try again to find a path to the door.
Movement
We use a single C struct
for monsters, projectiles and the protagonist.
This enables us to reuse the functions responsible for movement for all of them,
which saves us quite some flash space.
struct Character
{
uint8_t x;
uint8_t y;
enum {LOOK_MONSTER_MEMU, LOOK_PROTAGONIST, LOOK_FIREBALL, ...} look;
uint8_t lookstate; // to e.g. store whether the wings are turned upwards or downwards
uint32_t lastlookstatechg;
uint8_t width;// in pixels
uint8_t height; // in pixels
enum {DIRECTION_LEFT, DIRECTION_RIGHT} direction;
enum {DIRECTION_UP, DIRECTION_DOWN} verticaldirection;
int8_t jumpstate;
uint8_t initial_health;
int8_t health;
uint8_t damage;
uint8_t jumpheight;
enum {FOLLOW_PROTAGONIST, BACK_AND_FORTH, ...} movement;
uint8_t x_pace;
uint8_t y_pace;
};
First, there are the functions moveleft()
, moveright()
, moveup()
and movedown()
.
These take care of
- checking if there is an obstacle, e.g. a platform, in the respective direction,
- redrawing the character at its new position and
- clearing the pixels that are left over from the old position.
Then, there is also a function move()
which automatically decides in which direction to move.
For example, a monster with movement==FOLLOW_PROTAGONIST
will automatically move towards the protagonist
whenever a pointer to that very monster is passed to the function.
Course of the Game
To react to user input and automatically move monsters etc.,
we use multiple if
statements in an infinite loop
which check whether the respective timer has expired
and whether all preconditions are satisfied.
For example, when the user presses the B_RIGHT
button,
the protagonist is moved one pixel to the right and it is ensured that he will not move for another 50 ms.
Also, if the B_A
button is pressed
and the protagonist still has a rocket to shoot that is not yet moving,
the rocket is drawn to the screen and the protagonist loses one of his rockets.
To make sure that he still has the same number of rockets
when the game is resumed after turning the console off,
the number is also stored in the EEPROM
.
while (1)
{
if (nextmoveevent < getMsTimer())
{
if (B_RIGHT)
{
moveright(protagonist);
nextmoveevent = getMsTimer() + 50;
}
...
}
if (projectile->movement == HIDDEN
&& num_rockets > 0
&& nextshootevent < getMsTimer()
&& B_A)
{
projectile->movement = PROJECTILE;
draw(projectile);
num_rockets--;
eeprom_write_byte(&num_rockets_stored, num_rockets);
nextshootevent = getMsTimer() + 500;
}
if (monster->movement != HIDDEN && collision(protagonist, monster))
{
takingdamage(monster->damage);
}
...
}
This is just a small excerpt of the while
loop.