The PIO module on the RP2040 is quite a powerful peripheral, especially in terms of carrying out tasks that require precise timings and GPIO control. These tasks include handling communication protocols such as SPI, I2C, etc. In this project, we have utilized these capabilities of the PIO and the RP2040 as a whole to drive a 640x480 VGA display, and emulate the famous game - Piano Tiles integrated with spatial audio to provide an immersive experience. In the game, the user needs to intercept the falling tiles on the screen using a small base tile that is as the name suggests is present at the bottom of the screen. Each time a tile is intercepted a particular note is played. The spatial audio like effect is created based on which side the user intercepts the tile, i.e. if the tile is intercepted on the left side of the screen, the user will feel that the audio is coming from the left side and vice-versa for the right side. Headphones were used to implement this feature, driven using the PWM peripheral on the RP2040.
The repository containing the code is availabe on
GitHub can be accessed by clicking
here.
To get a little motivation for diving further into this, let's have a look at the end result.
For a detailed understanding of every component we will look at each of the features incorporated one by one.
First up is the VGA display. The VGA protocol works based on a few very precisely timed signals/pulses, which can be seen in the figure below.
Here the HSYNC and VSYNC signals play a major role in terms of
communicating
with the display and deciding both the temporal and spatial placement of pixels. The former has
control
over when a new row of pixels should be displayed, and the latter controls when a new frame needs to
come
in. All of this is controlled by the PIO module on the RP2040, which runs at a clock speed of
25MHz.The whole
protocol can be explained in brief as follows:
HSYNC
signal as the name suggests (Horizontal Sync) needs to be high for 640 clock cycles.
HSYNC signal is active-high and starts out as HIGH. During this
time, the R, G and B pins are set to varying volatages
between 0 and 0.7V, in every clock cycle.
HSYNC enters into its front porch,
for
which it goes to a LOW state for 16 clock cycles. Similarly it then moves onto the
sync Pulse and back porch part of the protocol and behaves as shown in the
figure
above.
VSYNC (vertical sync) controls the frame.
So,
during the whole HSYNC operation, the VSYNC remains HIGH.
VSYNC signal also has the front porch, sync pulse, and the
back porch part to it, and behaves based on the diagram displayed above, i.e. it stays
at a HIGH state during its front porch for 10 lines (time needed for the
HSYNC signal to go through 10 rows), and so on.
The Josytick is a part of the user interface and enables the user to interact with the game. We used a dual-axis analog joystick for our game, that spit out values based on its current position. Since, we only needed a single DOF (Degree of freedom) for playing the game, we did not need data from both the axes of the joystick.
The joystick is connected with an onboard ADC on the RP2040, which converts the analog signal obtained from the joystick into discrete digital levels. Here, we have used a 12-bit value to represent the analog data. Hence, the values from the joystick had a range of 0 to 4095. Once we had the values from the ADC, with some testing we caliberated the joystick based on the requirement for the game. Then the data was mapped to a range of 0 to 50. Finally, after that based on that value the position of the joystick was determined.
Now we move on to the audio part of the game from the display.The RP2040 doesn't have a DAC onboard, so we tried to use the PWM peripheral to generate audio notes. This was possible by encoding the notes into a series of duty cycle values and storing it to an array, which can be accessed by the main code and played whenever a tile is intercepted. Also, note that using a single PWM cycle for each value in the array will not create a analog like sound effect, since we need some sort of persistence to emulate actual audio. Therefore, each value in the array is played for 8 cycles to emulate that persistence of sound. This will become more apparent and clear once we take a look at the code.
GPIO 16 pin of the microcontroller is connected to the HSYNC terminal
of
the VGA Plug.
GPIO 17 pin is connected to the VSYNC terminal.
GPIO 18 pin is connected to the VGA Red terminal of the Plug. A 330 Ω
resistor is attached in between to reduce the voltage going to the VGA plug.
GPIO 19 pin is connected to the VGA Green terminal via a 330 Ω
resistor.
GPIO 20 pin is connected to the VGA Blue terminal via a 330 Ω
resistor.
VGA GND terminal of the VGA plug is connected to the GND pin on the
microcontroller to provide necessary grounding.
GPIO 28 pin of the Microcontroller is connected to the RNG terminal of
the
Audio Jack module. This terminal controls the Right side of the audio channel.
GPIO 15 pin is connected to the TIP terminal of the Audio Jack module.
This terminal controls the Left side of the audio channel.
GND pin is connected to the GND terminal of the module. This provides
grounding connection to the audio module.
GPIO 4 pin of the microcontroller.
3V3 pin, to provide
voltage to the restart pin based on user input.
GPIO 26 pin of the Raspberry Pi Pico is connected to the VRx terminal
of
the Joystick Module. This is responsible for controlling the joystick feedback in the horizontal (x)
axis.
3V3 pin of the microcontroller is connected to the 5V terminal of the
Joystick Module. This provides power to the module.
GND pin of the microcontroller is connected to the GND terminal of the
Joystick Module, which provides necessary grounding to the device.
Now let's take a look at the code that's driving our RP2040. Here also, we will split our code overview into smaller sections.
The following code snippet shows some of the standard as well as the hardware libraries that
we are going to use in our code. The hardware libraries included are a part of the
PICO-SDK
that abstract register access commands for that particular peripheral into functions and make them
available to the user. So, here we are using the adc, pio,
dma,
irq (interrupts) and the pwm peripheral. Finally, the
vga_graphics
library that we are using is created by Hunter
Adams,
which can be found here.
#include "vga_graphics.h" #include <stdio.h> #include <stdlib.h> #include "pico/stdlib.h" #include "pico/multicore.h" #include "hardware/pio.h" #include "hardware/dma.h" #include "hardware/adc.h" #include "registers.h" #include "hardware/irq.h" // interrupts #include "hardware/pwm.h" // pwm #include "hardware/sync.h" // wait for interrupt
HSYNC
; ; Hunter Adams (vha3@cornell.edu) ; HSync generation for VGA driver ; Program name .program hsync ; frontporch: 16 clocks (0.64us at 25MHz) ; sync pulse: 96 clocks (3.84us at 25MHz) ; back porch: 48 clocks (1.92us at 25MHz) ; active for: 640 clcks (25.6us at 25MHz) ; ; High for 704 cycles (28.16us at 25MHz) ; Low for 96 cycles (3.84us at 25MHz) ; Total period of 800 cycles (32us at 25MHz) ; pull block ; Pull from FIFO to OSR (only happens once) .wrap_target ; Program wraps to here ; ACTIVE + FRONTPORCH mov x, osr ; Copy value from OSR to x scratch register activeporch: jmp x-- activeporch ; Remain high in active mode and front porch ; SYNC PULSE pulse: set pins, 0 [31] ; Low for hsync pulse (32 cycles) set pins, 0 [31] ; Low for hsync pulse (64 cycles) set pins, 0 [31] ; Low for hsync pulse (96 cycles) ; BACKPORCH backporch: set pins, 1 [31] ; High for back porch (32 cycles) set pins, 1 [12] ; High for back porch (45 cycles) irq 0 [1] ; Set IRQ to signal end of line (47 cycles) .wrap % c-sdk { static inline void hsync_program_init(PIO pio, uint sm, uint offset, uint pin) { // creates state machine configuration object c, sets // to default configurations. I believe this function is auto-generated // and gets a name of <program name>_program_get_default_config // Yes, page 40 of SDK guide pio_sm_config c = hsync_program_get_default_config(offset); // Map the state machine's SET pin group to one pin, namely the `pin` // parameter to this function. sm_config_set_set_pins(&c, pin, 1); // Set clock division (div by 5 for 25 MHz state machine) sm_config_set_clkdiv(&c, 5) ; // Set this pin's GPIO function (connect PIO to the pad) pio_gpio_init(pio, pin); // pio_gpio_init(pio, pin+1); // Set the pin direction to output at the PIO pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true); // Load our configuration, and jump to the start of the program pio_sm_init(pio, sm, offset, &c); // Set the state machine running (commented out so can be synchronized w/ vsync) // pio_sm_set_enabled(pio, sm, true); } %}
VSYNC
; ; Hunter Adams (vha3@cornell.edu) ; VSync generation for VGA driver ; Program name .program vsync .side_set 1 opt ; frontporch: 10 lines ; sync pulse: 2 lines ; back porch: 33 lines (perhaps we try 32, since that's easier) ; active for: 480 lines ; ; Code size could be reduced with side setting pull block ; Pull from FIFO to OSR (only once) .wrap_target ; Program wraps to here ; ACTIVE mov x, osr ; Copy value from OSR to x scratch register activefront: wait 1 irq 0 ; Wait for hsync to go high irq 1 ; Signal that we're in active mode jmp x-- activefront ; Remain in active mode, decrementing counter ; FRONTPORCH set y, 9 ; frontporch: wait 1 irq 0 ; jmp y-- frontporch ; ; SYNC PULSE set pins, 0 ; Set pin low wait 1 irq 0 ; Wait for one line wait 1 irq 0 ; Wait for a second line ; BACKPORCH set y, 31 ; First part of back porch into y scratch register (and delays a cycle) ;set pins, 1 ; Raise high for back porch (delaying a set cycle) - REPLACED WITH SIDESET backporch: wait 1 irq 0 side 1 ; Wait for hsync to go high - SIDESET REPLACEMENT HERE jmp y-- backporch ; Remain in backporch, decrementing counter ;wait 1 irq 0 ; Wait for final (33rd) backporch line (eliminated) .wrap ; Program wraps from here % c-sdk { static inline void vsync_program_init(PIO pio, uint sm, uint offset, uint pin) { // creates state machine configuration object c, sets // to default configurations. I believe this function is auto-generated // and gets a name of <program name>_program_get_default_config // Yes, page 40 of SDK guide pio_sm_config c = vsync_program_get_default_config(offset); // Map the state machine's SET pin group to one pin, namely the `pin` // parameter to this function. sm_config_set_set_pins(&c, pin, 1); sm_config_set_sideset_pins(&c, pin); // Set clock division (div by 5 for 25 MHz state machine) sm_config_set_clkdiv(&c, 5) ; // Set this pin's GPIO function (connect PIO to the pad) pio_gpio_init(pio, pin); // pio_gpio_init(pio, pin+1); // Set the pin direction to output at the PIO pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true); // Load our configuration, and jump to the start of the program pio_sm_init(pio, sm, offset, &c); // Set the state machine running (commented out so can be synchronized with hsync) // pio_sm_set_enabled(pio, sm, true); } %}
RGB
; ; Hunter Adams (vha3@cornell.edu) ; RGB generation for VGA driver ; Program name .program rgb pull block ; Pull from FIFO to OSR (only once) mov y, osr ; Copy value from OSR to y scratch register .wrap_target set pins, 0 ; Zero RGB pins in blanking mov x, y ; Initialize counter variable wait 1 irq 1 [3] ; Wait for vsync active mode (starts 5 cycles after execution) colorout: pull block ; Pull color value out pins, 3 [4] ; Push out to pins (first pixel) out pins, 3 [2] ; Push out to pins (next pixel) jmp x-- colorout ; Stay here thru horizontal active mode .wrap % c-sdk { static inline void rgb_program_init(PIO pio, uint sm, uint offset, uint pin) { // creates state machine configuration object c, sets // to default configurations. I believe this function is auto-generated // and gets a name of <program name>_program_get_default_config // Yes, page 40 of SDK guide pio_sm_config c = rgb_program_get_default_config(offset); // Map the state machine's SET and OUT pin group to three pins, the `pin` // parameter to this function is the lowest one. These groups overlap. sm_config_set_set_pins(&c, pin, 3); sm_config_set_out_pins(&c, pin, 3); // Set clock division (Commented out, this one runs at full speed) // sm_config_set_clkdiv(&c, 5) ; // Set this pin's GPIO function (connect PIO to the pad) pio_gpio_init(pio, pin); pio_gpio_init(pio, pin+1); pio_gpio_init(pio, pin+2); // Set the pin direction to output at the PIO (3 pins) pio_sm_set_consecutive_pindirs(pio, sm, pin, 3, true); // Load our configuration, and jump to the start of the program pio_sm_init(pio, sm, offset, &c); // Set the state machine running (commented out, I'll start this in the C) // pio_sm_set_enabled(pio, sm, true); } %}
Now, we define some macros and global variables that will be used by our code. As described earlier,
headphones are
used to emulate the spatial audio aspect of the game, and to achieve this we control the pins
connected to the
left and right channels of the headphones. Therefore, two macros named AUDIO_PIN_LEFT
and
AUDIO_PIN_RIGHT are created to improve the code readability and portability. Finally
there is another
macro named DELAY_CYCLES which controls the delay between the left and right audio
channel. As mentioned
above we encoded our audio notes into an array that contains the duty cycle information for creating
a PWM signal. This
array needs to be accessed by our main code to play the actual notes, so we placed the array into a
header file and included
that into the source file. Finally, the global variables namely wav_position,
flag_start,
audio_note_indx and interception_side are used by the interrupt service
routines, and the
function that each of them perform will be explained in later sections.
// Audio PIN is to match some of the design guide shields. #define AUDIO_PIN_LEFT 28 // you can change this to whatever you like #define AUDIO_PIN_RIGHT 15 // you can change this to whatever you like #define DELAY_CYCLES 5 #include "audio_notes/A_major.h" #include "audio_notes/E_major.h" #include "audio_notes/B_major.h" #include "audio_notes/Din_2.h" #include "audio_notes/Tin_2.h" #include "audio_notes/Na_2.h" int wav_position = 0; int flag_start = 0; int audio_note_indx = 0; int interception_side = 0;
Apart from this some macros are defined for determining the static (like the x coordinate of the
falling tiles in each column)
coordinates for the base tile and the tiles falling in each column. The macros
LEFT_VERT_TILES,
MID_VERT_TILES, and RIGHT_VERT_TILES are defined to configure the x
coordinate (the top-left vertex)
of the tile in the left, middle and the right side respectively.
// Define the co-ordinates for the Tiles and the Joystick controlled base. #define LEFT_VERT 150 #define MID_VERT 290 #define RIGHT_VERT 430 #define LEFT_VERT_TILES 160 #define MID_VERT_TILES 300 #define RIGHT_VERT_TILES 440 #define RESTART_PIN 4 #define RESTART_PIN_REG ((volatile uint32_t *)(IO_BANK0_BASE + 0x010))
A restart button is integrated in the game to as the name suggests restart the game once
the game is over, i.e. a tile
is missed. This prevents the need to reset the microcontroller every time the game ends. To enable,
this operation a switch or
push button is connected to a GPIO pin on the RP2040, which is polled once the game is
over to determine when to
restart the game.
For controlling the left and right channels of the headphones, we have configured 2 separate
PWM
channels with interrupts. Following is the interrupt handler or the interrupt service routine for
the PWM
pin that is connected to the left channel of the headphone. This function defines the tasks that
need to be carried out
once a single PWM cycle completes, i.e. after one data element from the array that stores the audio
information is played.
Here we can see that the wav_position variable is used to index the data array that
contains our
audio note. Note that each data element in the array is played 8 times, to create a persistence
effect, because a simple
PWM signal is very "sharp" in a sense, and this gives us a sort of smoother effect. Hence, we right
shift the index by 3.
The WAV_DATA_LENGTH variable holds the length of the array, and therefore we left shift
it by 3. Keep in mind
that left shifting a number increases it and right shifting a number decreases it. The
PWM is a free-running
module and some sort of control is needed on the audio since we only want it to play once a tile is
intercepted. This is where the
flag_start variable comes into play, it determines whether an audio needs to be played
or not. If not the
GPIO pin connected to that channel is set to a LOW state. A brief
description of all the steps
this function performs is as follows:
flag_start variable is set indicating that an audio note needs
to be played.
audio_note_indx is used to determine which of the 3 notes needs to
be accessed currently. For
example we have used the NA, DIN and TIN notes and
0 is assigned to the
note NA, 1 is assigned to the note DIN and so on.
Some of these notes are generated using the Indian classicial musical instrument named
Tabla.
interception_side side variable. It has a value of 0 if the tile is
intercepted on the left side, a value of 1 if the tile is intercepted in the middle
and a value of 2 if the tile has been intercepted on the right (you get the idea).
To emulate spatial audio the side that the tile has been hit, needs to produce a louder
sound as compared to the opposite side, i.e. if the tile has been intercepted on the left, the
left
channel should have a higher amplitude as compared to the right channel. Similarly, in terms of
delay,
for the same example, the sound should reach the left channel first and then the right channel.
Therefore, for amplitude modulation, we add or subtract to the ON time present in
the array,
and for delay we manipulate the wav_position index such that the same audio note
played in
the left and right channel feels like it is shifted to the right in time.
else condition, when the flag_start variable is not
set,
as mentioned earlier we set the GPIO pin to 0 and increment our
audio_note_indx
variable untill the final note is reached, in which case it is reset to 0.
Similarly, the wav_position
is also set to 0 here. Note that the flag_start variable is unset to
enable that control over
when the audio notes are played.
void pwm_interrupt_handler() { pwm_clear_irq(pwm_gpio_to_slice_num(AUDIO_PIN_LEFT)); if (wav_position < (WAV_DATA_LENGTH<<3) - 1 && flag_start == 1) { // set pwm level // allow the pwm value to repeat for 8 cycles this is >>3 if (audio_note_indx == 0) { if (interception_side == 0 || interception_side == 1) { pwm_set_gpio_level(AUDIO_PIN_LEFT, WAV_DATA_NA[wav_position>>3]+20); } else { if (((wav_position>>3) - DELAY_CYCLES) <= 0) { pwm_set_gpio_level(AUDIO_PIN_LEFT, 0); } else { pwm_set_gpio_level(AUDIO_PIN_LEFT, WAV_DATA_NA[(wav_position>>3) - DELAY_CYCLES]-20); } } } else if (audio_note_indx == 1) { if (interception_side == 0 || interception_side == 1) { pwm_set_gpio_level(AUDIO_PIN_LEFT, WAV_DATA_DIN[wav_position>>3]+20); } else { if (((wav_position>>3) - DELAY_CYCLES) <= 0) { pwm_set_gpio_level(AUDIO_PIN_LEFT, 0); } else { pwm_set_gpio_level(AUDIO_PIN_LEFT, WAV_DATA_DIN[(wav_position>>3) - DELAY_CYCLES]-20); } } } else { if (interception_side == 0 || interception_side == 1) { pwm_set_gpio_level(AUDIO_PIN_LEFT, WAV_DATA_TIN[wav_position>>3]+20); } else { if (((wav_position>>3) - DELAY_CYCLES) <= 0) { pwm_set_gpio_level(AUDIO_PIN_LEFT, 0); } else { pwm_set_gpio_level(AUDIO_PIN_LEFT, WAV_DATA_TIN[(wav_position>>3) - DELAY_CYCLES]-20); } } } wav_position++; } else { // reset to start pwm_set_gpio_level(AUDIO_PIN_LEFT, 0); if (flag_start == 1) { wav_position = 0; flag_start = 0; if (audio_note_indx < 2) { audio_note_indx += 1; } else { audio_note_indx = 0; } } } }
A very similar approach is taken for implementing the interrupt handler for the right channel of the headphone.
void pwm_interrupt_handler_2() { pwm_clear_irq(pwm_gpio_to_slice_num(AUDIO_PIN_RIGHT)); if (wav_position < (WAV_DATA_LENGTH<<3) - 1 && flag_start == 1) { // set pwm level // allow the pwm value to repeat for 8 cycles this is >>3 if (audio_note_indx == 0) { if (interception_side == 1 || interception_side == 2) { pwm_set_gpio_level(AUDIO_PIN_RIGHT, WAV_DATA_NA[wav_position>>3]); } else { if (((wav_position>>3) - DELAY_CYCLES) <= 0) { pwm_set_gpio_level(AUDIO_PIN_RIGHT, 0); } else { pwm_set_gpio_level(AUDIO_PIN_RIGHT, WAV_DATA_NA[(wav_position>>3) - DELAY_CYCLES]-80); } } } else if (audio_note_indx == 1) { if (interception_side == 1 || interception_side == 2) { pwm_set_gpio_level(AUDIO_PIN_RIGHT, WAV_DATA_DIN[wav_position>>3]); } else { if (((wav_position>>3) - DELAY_CYCLES) <= 0) { pwm_set_gpio_level(AUDIO_PIN_RIGHT, 0); } else { pwm_set_gpio_level(AUDIO_PIN_RIGHT, WAV_DATA_DIN[(wav_position>>3) - DELAY_CYCLES]-80); } } } else { if (interception_side == 1 || interception_side == 2) { pwm_set_gpio_level(AUDIO_PIN_RIGHT, WAV_DATA_TIN[wav_position>>3]); } else { if (((wav_position>>3) - DELAY_CYCLES) <= 0) { pwm_set_gpio_level(AUDIO_PIN_RIGHT, 0); } else { pwm_set_gpio_level(AUDIO_PIN_RIGHT, WAV_DATA_TIN[(wav_position>>3) - DELAY_CYCLES]-80); } } } wav_position++; } else { // reset to start pwm_set_gpio_level(AUDIO_PIN_RIGHT, 0); if (flag_start == 1) { wav_position = 0; flag_start = 0; if (audio_note_indx < 2) { audio_note_indx += 1; } else { audio_note_indx = 0; } } } }
Now, let's move onto implementing the base tile, i.e. user interaction functionality. Here, we need
to move the base
tile based on the current position of the joystick. To do this, firstly the raw adc
value needs to be
read from the GPIO pin. We have used ADC0, so the function
adc_select_input(0)
will select the adc for us. The raw value read from the adc is stored in a
variable named
adc_x_raw and this value is used to calliberate the joystick. A range of values is used
in this process
for thresholding and reducing the de-bouncing effect. A 12-bit ADC is used
to discretize the raw values obtained from the joystick. Once the calliberation is done, the
resulting value is mapped
to a range of 0 to 100, and based on this value thresholding is done once again to determine where
the base tile needs
to be positioned. The fillRect function is a part of the vga_graphics
library that enables us
to draw a filled rectangle on the screen. This is done by using two for loops, and
calling the
drawPixel function in each iteration, which in-turn populates or updates the
VGA_DATA_ARRAY
based on the color and position of the pixel. Therefore, at the fillRect level, it
requires the top-left
vertex of the rectangle that needs to be drawn as its arguments, along with the width and height of
the rectangle and
finally the color that the rectangle needs to be filled with. At the end, we sleep for some time and
return the
adc_x variable. This will be required for determining the side on which the tile was
intercepted or where
the base tile is in reference to the current state of the screen.
uint act_adc() { adc_select_input(0); uint adc_x_raw = adc_read(); uint adc_x = 0; if (adc_x_raw > 1600 && adc_x_raw < 2400) { adc_x = 2048; } else { adc_x = adc_x_raw; } adc_x = (adc_x * 100) / 4095; printf("%d, %d\n", adc_x, adc_x_raw); if (adc_x == 50){ fillRect(MID_VERT,460,60,20,WHITE); fillRect(LEFT_VERT,460,60,20,0); fillRect(RIGHT_VERT,460,60,20,0); } else if (adc_x > 50) { fillRect(RIGHT_VERT,460,60,20,WHITE); fillRect(LEFT_VERT,460,60,20,0); fillRect(MID_VERT,460,60,20,0); }else if (adc_x < 50) { fillRect(LEFT_VERT,460,60,20,WHITE); fillRect(MID_VERT,460,60,20,0); fillRect(RIGHT_VERT,460,60,20,0); } sleep_ms(10); return adc_x; }
For a immersive and enjoyable experience the game animation needs to be engaging, lag-free and
smooth. This task of making
the tiles fall was repetitive in the sense that every tile needed to have this animation, hence we
created a helper function that
would avoid code repetition and improve readability. The draw_fill_rect function takes
in an arguments the
parameters required for the fillRect function, and another argument named
inc_dec. As explained above
the fillRect function draws rectangles on the screen, which now need to be animated.
Hence, to do this, we
first thought of erasing the whole drawn rectangle and creating another one at a position lower than
its last position to
give an impression that the tile is falling. However, this did not pan out well, it lead to choppy
animation since erasing
the whole rectangle was taking up quite a bit of time. Therefore, we decided to erase a small
rectangle from the top of the
tile and create another at the bottom of the tile, to emulate a falling tile. So, now every time the
tile needs to descend
a small part of it is erased from the top and a similar portion is created at the bottom. This is
where the inc_dec
parameter comes into play, it decides the height of the small portion that is going to be erased at
the top and created at the
bottom.
// Helper function to abstract the animation. void draw_fill_rect(short x, short y, short w, short h, char color, short inc_dec){ fillRect(x,y,w,h,color); fillRect(x,y,w,inc_dec,0); fillRect(x,y+h,w,inc_dec,color); sleep_ms(10); }
The score needs to be updated every time a tile is intercepted, to do this, firstly the entire area
that shows the score is
erased. For displaying score we need digits, which are passed as characters to the
drawChar function. We decided
to have a maximum of 3 digits to represent the score, and hence created a character array of size 3
that stores each digit of
the current score which is passed as a parameter to the update_score function. Each
digit is extracted from the
current score using the % operation with 10, and each digit becomes an
element in the character array.
Then the drawChar function is called thrice to draw each digit on the screen.
// Helper function to keep track of the user's score. void update_score(uint score){ fillRect(30,60,240,20,0); /* setCursor(30, 30); */ /* setTextSize(3); */ char str_score[3] = {'0', '0', '0'}; str_score[2] = (score % 10) + '0'; str_score[1] = ((score/10) % 10) + '0'; str_score[0] = (((score/10)/10) % 10) + '0'; drawChar(30, 60, str_score[0], WHITE, 0, 2); drawChar(45, 60, str_score[1], WHITE, 0, 2); drawChar(60, 60, str_score[2], WHITE, 0, 2); }
main
Function
Let's take a look at configuring the PWM channels for the audio generation. The
GPIO pins need to
be mapped to the PWM function. After that the interrupts are configured to fire when a
single PWM
cycle is complete, which is determined by the PWM_IRQ_WRAP macro. Once that is done,
each channel is assigned
an interrupt service routine. Finally, we get the default PWM config struct and update
the clkdiv and
wrap values. The latter is set to 250 which means that the counter will go
upto 250 and
then return to 0. At the end, both the GPIO pins are set to a
LOW state.
int main() { /////////////////////////////////// AUDIO CODE ////////////////////////////////////////// gpio_set_function(AUDIO_PIN_LEFT, GPIO_FUNC_PWM); gpio_set_function(AUDIO_PIN_RIGHT, GPIO_FUNC_PWM); int audio_pin_slice = pwm_gpio_to_slice_num(AUDIO_PIN_LEFT); int audio_pin_slice_2 = pwm_gpio_to_slice_num(AUDIO_PIN_RIGHT); // Setup PWM interrupt to fire when PWM cycle is complete pwm_clear_irq(audio_pin_slice); pwm_clear_irq(audio_pin_slice_2); pwm_set_irq_mask_enabled((1u<<6) | (1u<<7), true); // set the handle function above irq_add_shared_handler(PWM_IRQ_WRAP, pwm_interrupt_handler, 0); irq_add_shared_handler(PWM_IRQ_WRAP, pwm_interrupt_handler_2, 1); irq_set_enabled(PWM_IRQ_WRAP, true); // Setup PWM for audio output pwm_config config = pwm_get_default_config(); /* Base clock 176,000,000 Hz divide by wrap 250 then the clock divider further divides * to set the interrupt rate. * * 11 KHz is fine for speech. Phone lines generally sample at 8 KHz * * * So clkdiv should be as follows for given sample rate * 8.0f for 11 KHz * 4.0f for 22 KHz * 2.0f for 44 KHz etc */ pwm_config_set_clkdiv(&config, 8.0f); pwm_config_set_wrap(&config, 250); pwm_init(audio_pin_slice, &config, true); pwm_init(audio_pin_slice_2, &config, true); pwm_set_gpio_level(AUDIO_PIN_LEFT, 0); pwm_set_gpio_level(AUDIO_PIN_RIGHT, 0); /////////////////////////////////// AUDIO CODE //////////////////////////////////////////
Now we will move onto the configuration of the RESTART_PIN and the joystick. For the
former we first initialize the GPIO pin
connected the restart button, and then set it to input mode. After that, stdio
and the VGA library is initialized. For
the joystick, the adc peripheral is initialized. Then the GPIO pin is
mapped to the adc. Finally some variables
are defined which will keep track of our tiles' positions, the current score, the current joystick
position, etc. At the end we initialize the score board
and call the update_score function.
gpio_init(RESTART_PIN);
gpio_set_dir(RESTART_PIN, GPIO_IN);
// Initialize stdio
stdio_init_all();
// Initialize VGA
initVGA();
adc_init();
// Make sure GPIO is high-impedance, no pullups etc
adc_gpio_init(26);
// Initialize indices for the tiles, the score for the user, and the restart button status.
uint blue_indx = 20, green_indx = 40, cyan_indx = 60, joystick_pos = 0;
uint curr_score = 0, buttons_status = 0;
drawChar(30, 30, 'S', WHITE, 0, 2);
drawChar(45, 30, 'c', WHITE, 0, 2);
drawChar(60, 30, 'o', WHITE, 0, 2);
drawChar(75, 30, 'r', WHITE, 0, 2);
drawChar(90, 30, 'e', WHITE, 0, 2);
drawChar(105, 30, ':', WHITE, 0, 2);
update_score(curr_score);
// Sleep for some time, to give the user a chance to get READY.
sleep_ms(5000);
For the actual game play, we use 2 while loops, where the former controls the game
status, and the latter controls and animates the falling tiles.
Once the user misses a tile, the code exits from the inner while loop and polls the
restart button, once the button is pressed the game re-initializes.
For animation the y-cordinate of the tiles is incremented in multiples of
4, this multiplicative factor can be adjusted based on the
speed requirement, i.e. the speed at which the tiles should fall. Therefore, the higher the
multiplicative factor the faster the tiles fall. The y-cordinate
also helps us determine whether the tile has reached the bottom of the screen. Now, based on the
current joystick position and the state of the screen the
current_score, flag_start, and the interception_side
variables are updated. Every time a tile is hit, it blinks with red color
and then disappears. Finally, when a tile is missed the drawChar function is used to
display "Game Over!!" on the screen and the tiles are cleared. After that
the game waits for the restart button to be pressed. This complete process repeats indefinitely.
while(true) { while (true){ // Get the current joystick position. joystick_pos = act_adc(); // Check if the cyan colored tile, reached the bottom of the screen. If so // reset it's co-ordinates back to the top, and check if it was intercepted, // if it was change the tile's color to RED, and make it blink twice, suggesting // that the tile was intercepted, and update the user's score. If the tile was missed, // exit the loop. if (cyan_indx > 89) { cyan_indx = 0; fillRect(RIGHT_VERT_TILES,360,40,100,0); if (joystick_pos > 50) { sleep_ms(40); fillRect(RIGHT_VERT_TILES,360,40,100,RED); sleep_ms(40); fillRect(RIGHT_VERT_TILES,360,40,100,0); curr_score += 1; update_score(curr_score); flag_start = 1; interception_side = 2; __wfi(); } else { fillRect(RIGHT_VERT,460,60,20,0); break; } } // Check if the green colored tile, reached the bottom of the screen. If so // reset it's co-ordinates back to the top, and check if it was intercepted, // if it was change the tile's color to RED, and make it blink twice, suggesting // that the tile was intercepted, and update the user's score. If the tile was missed, // exit the loop. if (green_indx > 89) { green_indx = 0; fillRect(MID_VERT_TILES,360,40,100,0); if (joystick_pos == 50) { sleep_ms(40); fillRect(MID_VERT_TILES,360,40,100,RED); sleep_ms(40); fillRect(MID_VERT_TILES,360,40,100,0); curr_score += 1; update_score(curr_score); flag_start = 1; interception_side = 1; __wfi(); } else { fillRect(MID_VERT,460,60,20,0); break; } } // Check if the blue colored tile, reached the bottom of the screen. If so // reset it's co-ordinates back to the top, and check if it was intercepted, // if it was change the tile's color to RED, and make it blink twice, suggesting // that the tile was intercepted, and update the user's score. If the tile was missed, // exit the loop. if (blue_indx > 89) { blue_indx = 0; fillRect(LEFT_VERT_TILES,360,40,100,0); if (joystick_pos < 50) { sleep_ms(40); fillRect(LEFT_VERT_TILES,360,40,100,RED); sleep_ms(40); fillRect(LEFT_VERT_TILES,360,40,100,0); curr_score += 1; update_score(curr_score); flag_start = 1; interception_side = 0; __wfi(); } else { fillRect(LEFT_VERT,460,60,20,0); break; } } // Animate the tiles, by erasing a small rectangle of dimensions 4x40 from it's // top and creating a rectangle at the bottom with the exact same dimensions. This // emulates the falling tiles. draw_fill_rect(LEFT_VERT_TILES,(blue_indx*4),40,100,BLUE,4); draw_fill_rect(MID_VERT_TILES,(green_indx*4),40,100,GREEN,4); draw_fill_rect(RIGHT_VERT_TILES,(cyan_indx*4),40,100,CYAN,4); // Update the co-ordinates for each of the tile. cyan_indx++; green_indx++; blue_indx++; } // Erase the tiles, to clear the screen. fillRect(LEFT_VERT_TILES,(blue_indx*4),40,100,0); fillRect(MID_VERT_TILES,(green_indx*4),40,100,0); fillRect(RIGHT_VERT_TILES,(cyan_indx*4),40,100,0); // Display Game over. drawChar(180, 240, 'G', WHITE, 0, 5); drawChar(210, 240, 'A', WHITE, 0, 5); drawChar(240, 240, 'M', WHITE, 0, 5); drawChar(270, 240, 'E', WHITE, 0, 5); drawChar(300, 240, ' ', WHITE, 0, 5); drawChar(330, 240, 'O', WHITE, 0, 5); drawChar(360, 240, 'V', WHITE, 0, 5); drawChar(390, 240, 'E', WHITE, 0, 5); drawChar(420, 240, 'R', WHITE, 0, 5); drawChar(450, 240, '!', WHITE, 0, 5); drawChar(480, 240, '!', WHITE, 0, 5); // Wait here untill restart button is pressed. buttons_status = register_read(RESTART_PIN_REG); printf("0x%08x\n", buttons_status); while (buttons_status == 0){ buttons_status = register_read(RESTART_PIN_REG); /* printf("0x%08x\n", buttons_status); */ sleep_ms(10); } // Clear the screen, and reset the score. fillRect(180,240,400,100,0); curr_score = 0; update_score(curr_score); } return 0; }
First off , we tried implementing an animation of a single tile using the fillRect
function which was defined in the vga_graphics library. This function uses the
drawPixel function. The fillRect uses the coordinates of the top left vertex
of the rectangle to do so. We began by trying to draw a single rectangle and getting it to move
horizontally. We did this by drawing a rectangle and then erasing it. We then drew another rectangle by
setting the top left vertex coordinate as the coordinate of the top right vertex of the previous
rectangle that we just erased . We repeated this process along the entire X axis to make it look like
that the tile was moving horizontally across the X axis. Once we were able to do this , we tried to
implement the tiles moving top to down since that was the use case for our project.
Once we were able to replicate a pattern of a falling tile , we did this for 3 columns since we have implemented 3 columns in our game. We then had to create our base tile at the bottom of the screen that we would use to intercept the falling tiles. Once we had the base tile and the falling tiles aspect of the game set up exactly how we wanted , we implemented a score tracker that would keep incrementing as and when you intercept tiles and would end the game if you miss a certain tile.
We then tried to find ways of implementing spatial audio where a more prominent sound would be played on
the left or the right channel of the headphone depending on the side of the screen that the tile had
been intercepted. Since the RP2040 does not have a Digital to Analog converter, we tried using the PWM
peripheral to produce audio notes. We referred to a piece of code that takes .wav files as
input and converts it into PWM data. Initially , we played around with the amplitudes of the audio notes
to bring spatial audio in effect , however , the difference in audio levels was not very significant. To
overcome this , we then implemented delays on top of amplitude reduction to bring about a perciptible
difference in the right and left channels of the headphone.
These pictures and videos demonstrate the working of our project. We learnt a lot during the course, and were able to get a hands-on experience with microcontrollers and various communication protocols.