Cajon Extreme - Denisse Rojas - Flora Weil
CONCEPTION AND DESIGN
Cajón Extreme is an interactive game based on Dance Dance Revolution that merges the excitement of an arcade game with the cultural uniqueness of the Peruvian cajón. It strives to teach basic rhythms of the cajón while sharing an appreciation for Peruvian traditional music, specifically the festejo genre. The player is guided intuitively with on-screen arrows to where to press at different times, representing the beat from festejo songs.
For this project, I learned a lot in an attempt to overcome the obstacles that appeared on my way. First, I taught myself how to use the cajón with the help of several YouTube tutorials. Although I know very little about percussion instruments, I transcribed the notes of the first song, "Mal Paso", into a music sheet to understand the beats. This also helped me see the repetition in the beats, making me able to hard-code part of it for the user testing.
I also researched festejo, specifically what it means culturally and where it is performed. Previously, I had only known the songs because of how they always appeared at family reunions and school performances. This project allowed me to dive deeper into the significance and history of these songs. The research directly influenced the design of my characters and game environment to make sure they were truly representative.
I also researched the interface of DDR games so that my version had clear similarities. Of all the versions of DDR, I really liked the Dance Dance Revolution Extreme (PS2) version. That inspired my desire to customize my own 3D low-polysurface characters on Blender. I thought this was particularly important to my game because festejo songs are always accompanied by their traditional dances. However, this idea wasn't able to be completed for the user test or the final presentation due to time constraints.
The sketch of the Cajon and its components is on the left. The cajon itself was created by laser cutting four sheets of 5 mm plywood. I made sure that the measurements were of a real cajon so that the user had a more realistic experience. To ensure that it didn't break, Dalin cut a big piece of wood I was able to put inside the box.
During the user testing, I got feedback on the interface of my project like how it wasn't clear which part of the cajón the user had to touch. Andy recommended reconstructing the horizontal row of arrows to one that resembled the positions of the cajón. Moreover, we changed the sensors from pressure sensors to vibration sensors to test if they were more effective in inputting to the system which places the cajón had been hit.
After attempting to use the vibration sensors, I had to return to the pressure sensors. This was until Flora suggested using touch sensors. After integrating the touch sensors, each note was able to distinguish itself when played. Also, I liked how they output digital data instead of analogue data. That way I spent no time searching for the threshold of each sensor.
FABRICATION AND PRODUCTION
The most difficult part initially was how to sync the arrows with the cajón rhythms. During the first tests, the arrows were not aligned with the beats of the music, making the game flow disturbed. I resolved this by fine-tuning the timing parameters in the code and testing the game multiple times to ensure that the arrows matched the rhythm more precisely. I also tired implementing the BeatDetection library from processing for the song "El Chacombo" which proved to be partially effective. I could not apply this "technique" to the song "Mal Paso" given how there were no videos of a person just playing the cajon part of the song. This adjustment went a long way in improving the user experience.
Another obstacle was to calculate the distance between the moving arrow and the "arrow limit." For this part, Nawaf helped me by correcting my distance functions and identifying errors with my Arduino code. With his help and Flora's, signals of when the user had pressed the sensor were visible on the game.
------------------------
Besides the touch sensors, I added 3 pressure sensors to act as buttons. I did this to have all the sensors in the cajón and avoid using the computer's keys.
I designed the buttons on Rhino taking into consideration the shape these buttons have in the original DDR machine. I made the insides hollow so that I could also put LEDs inside but due to selecting the wrong support, I could not take it out. Eventually, I ended up not adding the LEDs as part of the design.
Also, I designed the red square so that the game starts once the user sits on the cajon. I made the height of it purposely short so that it doesn't bother the users once they sit on it for the whole game. I also wanted to add this feature so that with the help of a function that keeps track of the pressure exerted and for how much time, it identifies if the user is no longer in the Cajon and for how long. If there is no response, it goes to the menu in preparation for another user to play the game. This last part wasn't implemented either due to time limitations.
The pressure sensor was put between the cap of the buttons and some foam tape.
------------------------
Although I did not implement the use of personalized avatars, I still wanted to capture the spirit of Festejo through designing characters that would dance to the beat of the music. At first, creating these avatars was rather time-consuming, but once the code was refined and the animation was simplified, I could create characters that maintained the user's interest.
VIDEO DOCUMENTATION
CONCLUSION
The goal of Cajon Extreme was to combine Dance Dance Revolution with the cajón. This experience would foster an appreciation for Peruvian music in the user while learning how to play the instrument. I think that given how my project wasn't finished, it could not achieve this goal. A lot of the features I wanted to include that were important because of how they made the game representative and accurate failed or I wasn't able to complete them. Even for my final presentation, I had barely the minimum viable product. If I had more time and a better knowledge of processing, I would have fixed and implemented the features I mentioned in my sketches.
My audience did not respond as expected. Even though they were hitting the marks at the right time, the sound the Cajon produced wasn't as similar to the way it was played. This made me think that I should implement a brief tutorial on how to follow the basic rhythm of the Cajon and set the flow before the person starts playing. While I researched more on DDR games, I saw that some versions also included guided "tutorials" to get the player used to moving around and pressing the arrows at the expected time.
I believe my project was partially interactive. On one hand, there was a clear exchange of information between the user and the computer. As the computer showed which arrows to press, the user responded by pressing the right sensors on time. Then, the computer replied by indicating whether the response was good enough. In the end, the user would get feedback on their overall performance through a grade. This mimicking of a conversation does align with my definition of interactivity.
The part I believe I lacked was that the responses to the performance were too repetitive and limited. I believe that receiving messages of miss, ok, great or perfect wouldn't be encouraging enough to keep people wanting to improve. I believe I could enhance this with the addition of the 3D character I mentioned earlier and changes in the background. If the user plays well then the character dances nicely and the background is pretty, but if they miss a lot, the dancer struggles too and the background is ugly.
This project has taught me the importance of reaching out for help early. Since the beginning, I had a lot of issues which I tried to solve by myself but couldn't. Also, there were so many tools available that I didn't use and would have been best to implement since the beginning. The touch sensors were implemented a night before the final presentation. Then, I learned about the beat-detection library 30 minutes before the presentation. Next time I will do better research considering all the tools I can use. Also, this would avoid the need to use AI tools like ChatGPT which ended up confusing me more.
Code
const int sensor1 = 2;
const int sensor2 = 3;
const int sensor3 = 4;
const int sensor4 = 5;
const int pressureSensorPin = A5;
const int pressureThreshold = 150;
int psensorValue = 0;
const int debounceTime = 1000;
unsigned long previousMillis = 0;
void setup() {
Serial.begin(9600);
pinMode(pressureSensorPin, INPUT);
pinMode(sensor1, INPUT);
pinMode(sensor2, INPUT);
pinMode(sensor3, INPUT);
pinMode(sensor4, INPUT);
}
void loop() {
//Serial.print("Test");
String sensor_values="";
long currentMillis = millis();
// if (currentMillis - previousMillis > debounceTime) {
if (digitalRead(sensor1) == HIGH) {
// Serial.println(1);
//sensor_values += String(sensor1)+ ",";
Serial.print
}
else{
sensor_values +="0,";
}
if (digitalRead(sensor2) == HIGH) {
// Serial.println(2);
sensor_values +=String(sensor2)+ ",";
}
else{
sensor_values +="0,";
}
if (digitalRead(sensor3) == HIGH) {
// Serial.println(3);
sensor_values +=String(sensor3)+ ",";
}
else{
sensor_values +="0,";
}
if (digitalRead(sensor4) == HIGH) {
// Serial.println(4);
sensor_values +=String(sensor4)+ ",";
}
else{
sensor_values +="0,";
}
previousMillis = currentMillis;
psensorValue = analogRead(pressureSensorPin);
bool currentState = psensorValue > pressureThreshold;
// Serial.println(psensorValue);
sensor_values +=String(psensorValue)+"\n";
Serial.print(sensor_values);
Serial.println();
if (currentState) {
Serial.println(5);
}
}
import processing.sound.*;
import processing.serial.*;
Serial serialPort;
int pressDuration = 0;
int thresholdDuration = 3000;
int NUM_OF_VALUES_FROM_ARDUINO = 5; // Number of sensors (change as needed)
int[] arduino_values = new int[NUM_OF_VALUES_FROM_ARDUINO];
int sensor1 = arduino_values[0];
int sensor2 = arduino_values[1];
int sensor3 = arduino_values[2];
int sensor4 = arduino_values[3];
SoundFile player;
PImage perfectImage, greatImage, okImage, missImage;
PImage back1, back2;
int feedbackTimer = 0;
boolean feedbackShown = false;
String[] audioFiles = {"malPasoAudio.wav", "chacomboAudio.wav", "ingaAudio.wav"};
String selectedAudioFile = "";
PImage arrowLU, arrowLD, arrowRD, arrowRU;
int arrowSpawnInterval = 635; // Interval in milliseconds between arrows
int luSpawnInterval = 635; // Interval in milliseconds between LU arrows
int rdSpawnInterval = 900; // Interval for RD arrows, assuming twice the LU interval
int lastArrowSpawnTime = 0;
float[] targetY = new float[4]; // Target Y positions for each arrow lane
float[] targetX = new float[4]; // Target X positions for each arrow lane
float[] arrowRotations = {radians(45), radians(90), radians(-90), radians(-45)};
float[][] asset6_positions = {
{300, 300}, // Asset 6 position 1
{500, 300}, // Asset 6 position 2
{300, 500}, // Asset 6 position 3
{500, 500} // Asset 6 position 4
};
boolean leftPressed = false;
boolean rightPressed = false;
boolean upPressed = false;
boolean downPressed = false;
int countdown = 5;
int countdownStartTime;
boolean isSeated = false;
int gameState = 0;
PImage[] songImages = new PImage[3];
String[] songTitles = {
"Mal Paso by Eva Ayllon",
"El Chacombo by Arturo Cavero and Oscar Aviles",
"Inga by Eva Ayllon"
};
// Arrays to store information about moving arrows
float[] arrowX = new float[100]; // X positions of arrows
float[] arrowY = new float[100]; // Y positions of arrows
int[] arrowDirection = new int[100]; // Directions of arrows
boolean[] arrowActive = new boolean[100]; // Whether each arrow is active
int arrowCount = 0; // Number of active arrows
float arrowSpeed = 5; // Speed of arrow movement
int selectedSongIndex = 0;
PImage arrow;
int numArrows = 4;
int arrowSpacing = 20;
float arrowScale = 1;
final float PERFECT_THRESHOLD = 10;
final float GOOD_THRESHOLD = 30;
final float OK_THRESHOLD = 50;
int songStartTime = 0;
boolean songPlaying = false;
void setup() {
size(1500, 850);
printArray(Serial.list());
serialPort = new Serial(this, "/dev/cu.usbmodem11301", 9600);
songImages[0] = loadImage("malPaso.jpeg");
songImages[1] = loadImage("chacombo.jpg");
songImages[2] = loadImage("inga.jpg");
arrowLU = loadImage("Asset11.png");
arrowLD = loadImage("Asset9.png");
arrowRD = loadImage("Asset8.png");
arrowRU = loadImage("Asset7.png");
back1 = loadImage("background1");
back2 = loadImage("background2");
perfectImage = loadImage("perfect.png");
greatImage = loadImage("great.png");
okImage = loadImage("ok.png");
missImage = loadImage("miss.png");
arrow = loadImage("Asset6.png");
arrow.resize((int)(arrow.width * arrowScale), (int)(arrow.height * arrowScale));
float totalWidth = numArrows * arrow.width + (numArrows - 1) * arrowSpacing;
float startX = (width / 2 - totalWidth / 2) / 2;
float centerY = height / 2;
for (int i = 0; i < 4; i++) {
targetX[i] = startX + i * (arrow.width + arrowSpacing);
targetY[i] = centerY;
}
}
void draw() {
background(0);
handlePressureSensor();
if (gameState == 0) {
showMenu();
} else if (gameState == 1) {
showCarousel();
} else if (gameState == 2) {
text(arduino_values[0], 700, 100);
text(arduino_values[1], 900, 100);
text(arduino_values[2], 1100, 100);
text(arduino_values[3], 1300, 100);
showCountdownAndPlayAudio();
updateAndDisplayMovingArrows();
if (arduino_values[0]==1){
image(perfectImage,200,400);
}
if (arduino_values[1]==1){
image(missImage,350,400);
}
if (arduino_values[2]==1){
image(greatImage,500,400);
}
if (arduino_values[3]==1){
image(missImage,700,400);
}
} else if (gameState == 3) {
showEndPlaceholder();
}
getSerialData();
}
void handlePressureSensor() {
if (gameState == 0 && pressDuration > thresholdDuration) {
// Transition from menu to song selection when sensor is pressed for 3+ seconds
isSeated = true;
gameState = 1;
}
}
void displayArrows() {
float totalWidth = numArrows * arrow.width + (numArrows - 1) * arrowSpacing;
float startX = (width / 2 - totalWidth / 2) / 2;
float centerY = height / 2;
for (int i = 0; i < numArrows; i++) {
float x = startX + i * (arrow.width + arrowSpacing);
float rotation = 0;
pushMatrix();
translate(x, centerY);
switch(i) {
case 0:
rotation = radians(45);
break;
case 1:
rotation = radians(90);
break;
case 2:
rotation = radians(-90);
break;
case 3:
rotation = radians(-45);
break;
}
rotate(rotation);
imageMode(CENTER);
image(arrow, 0, 0);
popMatrix();
}
}
void keyPressed() {
if (gameState == 0 && key == 'x') {
isSeated = true;
gameState = 1; // Transition to carousel
} else if (gameState == 1) {
if (key == 'a') {
selectedSongIndex = (selectedSongIndex - 1 + songImages.length) % songImages.length;
} else if (key == 'd') {
selectedSongIndex = (selectedSongIndex + 1) % songImages.length;
} else if (key == ENTER) {
selectedAudioFile = audioFiles[selectedSongIndex]; // Set selected audio
gameState = 2;
countdownStartTime = millis();
resetPlayer();
}
} else if (gameState == 3 && key == 'x') {
resetGame();
}
}
void resetPlayer() {
if (player != null) {
player.stop();
//player = null; // Reset player object
}
}
void resetGame() {
resetPlayer(); // Ensure the audio player is stopped
gameState = 0; // Reset to the initial menu state
isSeated = false; // Reset seating status
selectedAudioFile = ""; // Clear the selected audio file
selectedSongIndex = 0; // Reset the song index
arrowCount = 0; // Reset the arrow count if needed
}
void showMenu() {
fill(255);
textSize(50);
textAlign(CENTER, CENTER);
text("Welcome to the Dancing Game", width /2, height /3);
textSize(30);
if (!isSeated) {
text("Press 'x' to sit on the cajón and start!", width /2, height /2);
}
}
void spawnArrow(int direction) {
if (arrowCount < arrowX.length) {
arrowX[arrowCount] = targetX[direction];
arrowY[arrowCount] = height; // Start at the bottom of the screen
arrowDirection[arrowCount] = direction;
arrowActive[arrowCount] = true;
arrowCount++;
}
}
void showCountdownAndPlayAudio() {
int elapsedTime = millis() - countdownStartTime;
int remainingTime = countdown - elapsedTime / 1000;
if (remainingTime > 0) {
// Countdown display
fill(255);
textSize(80);
textAlign(CENTER, CENTER);
text("Starting in " + remainingTime + " seconds...", width / 2, height / 2);
} else {
if (player == null) {
player = new SoundFile(this, selectedAudioFile);
player.play();
songStartTime = millis(); // Record the start time
songPlaying = true;
}
displayArrows();
// Display song title
fill(255);
textSize(20);
textAlign(RIGHT, BOTTOM);
text("Playing " + songTitles[selectedSongIndex], width - 20, height - 20);
// Display elapsed time
if (songPlaying) {
displaySongTimer();
}
// Check if the song has finished playing
if (player != null && !player.isPlaying()) {
gameState = 3; // Transition to end placeholder when song ends
resetPlayer(); // Reset the player after the song ends
}
}
}
void displaySongTimer() {
// Calculate elapsed time
int elapsedTime = millis() - songStartTime;
int seconds = (elapsedTime / 1000) % 60;
int minutes = (elapsedTime / 1000) / 60;
// Display the time on the screen
fill(255, 255, 0);
textSize(30);
textAlign(LEFT, TOP);
text("Time: " + nf(minutes, 2) + ":" + nf(seconds, 2), 20, 20);
}
void updateAndDisplayMovingArrows() {
for (int i = 0; i < arrowCount; i++) {
if (arrowActive[i]) {
arrowY[i] -= arrowSpeed;
pushMatrix();
translate(arrowX[i], arrowY[i]);
rotate(arrowRotations[arrowDirection[i]]);
PImage currentArrow;
switch (arrowDirection[i]) {
case 0: currentArrow = arrowLU; break;
case 1: currentArrow = arrowLD; break;
case 2: currentArrow = arrowRD; break;
case 3: currentArrow = arrowRU; break;
default: currentArrow = null;
}
imageMode(CENTER);
image(currentArrow, 0, 0);
popMatrix();
// Check if the arrow has reached its target position
if (arrowY[i] <= targetY[arrowDirection[i]]) {
arrowActive[i] = false; // Deactivate the arrow
}
}
}
removeInactiveArrows();
int elapsedTimeSinceStart = millis() - countdownStartTime - countdown * 1000;
if (selectedAudioFile.equals("malPasoAudio.wav")) {
if (elapsedTimeSinceStart >= lastArrowSpawnTime + luSpawnInterval) {
spawnArrow(0); // Spawn LU arrows
lastArrowSpawnTime += luSpawnInterval;
if ((elapsedTimeSinceStart / luSpawnInterval) % 2 == 1) { // Every second LU spawn, add RD
spawnArrow(2); // Spawn RD arrows
}
}
}
}
void removeInactiveArrows() {
int activeCount = 0;
for (int i = 0; i < arrowCount; i++) {
if (arrowActive[i]) {
arrowX[activeCount] = arrowX[i];
arrowY[activeCount] = arrowY[i];
arrowDirection[activeCount] = arrowDirection[i];
arrowActive[activeCount] = true;
activeCount++;
}
}
arrowCount = activeCount;
}
void showCarousel() {
imageMode(CENTER);
image(songImages[selectedSongIndex], width/2, height/2, 600, 400);
fill(255);
textSize(40);
textAlign(CENTER, CENTER);
text(songTitles[selectedSongIndex], width/2, height/1.2);
textSize(25);
text("Press 'a' to go back,'d' to move forward,and Enter to select", width/2, height-50);
}
void showEndPlaceholder() {
fill(255);
textSize(50);
textAlign(CENTER, CENTER);
text("Song Finished!", width/2, height/3);
textSize(30);
text("Press 'x' to return to the main menu", width/2, height/2);
}
void stop() {
if (player!=null&&player.isPlaying()) {
player.stop();
}
super.stop();
}
class MovingArrow {
float x, y;
float speed;
int direction;//0:45°,1:90°,2:-90°,3:-45° boolean active=true;
MovingArrow(float x, float y, float speed, int direction) {
this.x=x;
this.y=y;
this.speed=speed;
this.direction=direction;
}
void move() {
y-=speed;
}
void display() {
pushMatrix();
translate(x, y);
rotate(radians(direction==0?45:direction==1?90:direction==2?-90:-45));
imageMode(CENTER);
image(arrow, 0, 0);
popMatrix();
}
boolean checkCollision(float targetY) {
return y<=targetY;
}
}
void checkArrowHit(int sensorHit) {
for (int i = 0; i < arrowCount; i++) {
if (arrowActive[i] && arrowDirection[i] == sensorHit) {
float movingArrowTop = arrowY[i] - arrow.height / 2; // Top of the moving arrow
float targetArrowTop = targetY[sensorHit] - arrow.height / 2; // Top of the target (Asset6) arrow
float accuracy = abs(movingArrowTop - targetArrowTop);
String hitQuality;
int score;
if (accuracy <= PERFECT_THRESHOLD) {
hitQuality = "PERFECT";
score = 100;
} else if (accuracy <= GOOD_THRESHOLD) {
hitQuality = "GOOD";
score = 75;
} else if (accuracy <= OK_THRESHOLD) {
hitQuality = "OK";
score = 50;
} else {
hitQuality = "MISS";
score = 0;
}
arrowActive[i] = false; // Deactivate the hit arrow
// Display hit quality and score
println("Hit arrow " + sensorHit + ": " + hitQuality + " (Score: " + score + ")");
// You can add more game logic here, like updating total score, combo, etc.
break; // Exit the loop after handling one arrow hit
}
}
}
void displayAssetPNG() {
String filename = "asset_6.png";
PImage img = loadImage(filename); // Load the asset image
// Loop through all asset 6 positions and display the image at each location
for (int i = 0; i < asset6_positions.length; i++) {
image(img, asset6_positions[i][0], asset6_positions[i][1]); // Display at each asset 6 position
}
}
void getSerialData() {
while (serialPort.available() > 0) {
String in = serialPort.readStringUntil(10); // 10 = '\n' Linefeed in ASCII
if (in != null) {
print("From Arduino: " + in);
String[] serialInArray = split(trim(in), ",");
if (serialInArray.length == NUM_OF_VALUES_FROM_ARDUINO) {
for (int j = 0; j < serialInArray.length; j++) {
arduino_values[j] = int(serialInArray[j]);
}
}
}
}
}