Concept Overview)
This post includes edits as there were some feature changes after presenting to the class before the IM showcase. This reflects the finalized version that was shown on the day of IM showcase.
To go over the basic format of the game, this is a bubble shooter game. The player shoots a bubble with a colour- if it hits another ball with the same colour, they pop, giving the player scores.
I made this a time-attack format: I gave each game 1 minute to make sure that the game doesn’t drag on for too long and the game is intense.
I also wanted to create something fun and stress relieving- so I used the actions ‘punching’ and ‘screaming.’
The shooter’s direction/angle is controlled by the joystick (rotate). This was created by glueing a sphere-shaped pencil sharpener to a potentiometer.
The shooter will shoot if the player hits the table with their hand with the glove on- this has a force sensor on it.
If the player shouts loud and long enough, the gauge at the bottom right corner will fill up- once filled, it will provide a black ball. A black ball is a bonus: this ball can pop any coloured balls if it hits them.
Challenges)
There were multiple challenges. The first was figuring out how to make sure there’s a reason for the player to shout. Another thing I had to figure out was how to make the game intense as the game is pretty simple.
Also, on the day of the IM showcase, there were multiple players that came to try the game. Eventually, after about an hour, the force sensor on the glove ripped. I had a back up in my code, that allowed the ENTER Key to work as a shooter too. So, from some point of the IM showcase, the glove was replaced by the ENTER key.
Code)
There are multiple pages like initializePage, startPage, gamePage, gameOverPage, leaderboard etc. Depending on conditions and button clicks, the currentPage variable is assigned as different pages.
Set Up / Draw:
function setup() { createCanvas(600, 700); nextBallColor = random(bubbleColors); shooter = new Shooter(width / 2, height - 50, nextBallColor); shooter.canShootFlag = true; leaderboard = new Leaderboard(); mic = new p5.AudioIn(); mic.start(); timer = 60000; // set game timer to 60 seconds if (!serialActive) { currentPage = new InitializePage(); } else { currentPage = new StartPage(); } } function draw() { clear(); background(220); currentPage.display(); currentPage.handleKeyPress(); if (currentPage instanceof GamePage) { // update timer if (timer > 0) { timer -= deltaTime; } else { currentPage.endGame(); } volume = mic.getLevel() * 10000; //console.log('Volume: ', volume); ////////////////////////////////// //SEND TO ARDUINO HERE (handshake) ////////////////////////////////// let sendToArduino = score + "\n"; writeSerial(sendToArduino); shooter.update(); shooter.display(); for (let i = balls.length - 1; i >= 0; i--) { balls[i].update(); balls[i].display(); // check if ball hits top border of game box if (balls[i].y - balls[i].radius <= 100) { balls[i].stop(); } // check if ball hits another ball for (let j = 0; j < balls.length; j++) { if (i !== j && balls[i].intersects(balls[j])) { if ((balls[i].color === 'black' || balls[i].color === balls[j].color) && !balls[i].isPopping() && !balls[j].isPopping()) { balls[i].delayedPop(); balls[j].delayedPop(); shooter.setCanShoot(false); setAutoShootTimeout(); autoShoot(); } else { balls[i].stop(); } } } // check if ball hits side border of game box if (balls[i].x - balls[i].radius <= 50 || balls[i].x + balls[i].radius >= 550) { balls[i].bounceOffWall(); } } if (volume > 1000 && gaugeValue < maxGaugeValue){ gaugeValue += 1; } else if (volume < 700 && gaugeValue > 0) { gaugeValue -= 1; } if (gaugeValue >= 100) { nextBallColor = 'black'; shooter.setColor('black'); gaugeValue = 0; } // set values & draw guage let gaugeX = width - 120; let gaugeY = height - 110; let gaugeWidth = 100; let gaugeHeight = 20; drawGauge(gaugeX, gaugeY, gaugeWidth, gaugeHeight, gaugeValue, maxGaugeValue); } // }
In the setup function, I start the microphone input and set up the timer for the game. I also check if the serial port is connected and set the currentPage. (If not connected, we go to InitializePage and if connected, it goes to startPage.)
Draw function displays each display part of different pages. So, each page has a display function. It also does a few jobs when the currentPage is gamePage:
– communicate with arduino
– make the timer run
– calculate volume/gauge for display
gamePage:
class GamePage { constructor() { stroke('white'); this.homeButton = new Button(188, 56, "Home", 138, 50, true); this.restartButton = new Button(410, 56, "Restart", 138, 50); setAutoShootTimeout(); } score () { // show score textAlign(LEFT, TOP); textSize(20); fill(0); text('Score: ' + score, width - 130, 50); } display() { // bring border clear(); background('black'); image(gameBorderImage, -76, -10, 756, 750); shooter.update(); shooter.display(); // update & draw balls for (let i = balls.length - 1; i >= 0; i--) { // check if balls cross the bottom border of game box // shot value is used to make sure it doesn't consider newly shot balls if (balls[i].y + balls[i].radius >= height - 60 && balls[i].y - balls[i].radius <= height - 60 && balls[i].shot == 1 && !balls[i].isPopping()) { this.endGame(); } balls[i].update(); balls[i].display(); // check if balls touch the top border of game box if (balls[i].y - balls[i].radius <= 100) { balls[i].stop(); } // check if ball touches another ball for (let j = 0; j < balls.length; j++) { if (i !== j && balls[i].intersects(balls[j])) { if ((balls[i].color === 'black' || balls[i].color === balls[j].color) && !balls[i].isPopping() && !balls[j].isPopping()) { // if ball hits another ball of same colour, make them disappear after a short delay balls[i].delayedPop(); balls[j].delayedPop(); shooter.setCanShoot(false); // prevent shooting until the balls pop setAutoShootTimeout(); } else { // ball hit another ball of different colour balls[i].stop(); } } } // check if balls touch the side borders of game box if (balls[i].x - balls[i].radius <= 50 || balls[i].x + balls[i].radius >= 550) { balls[i].bounceOffWall(); } } // display timer textAlign(LEFT, BOTTOM); textSize(20); fill('white'); text("Time: " + Math.ceil(timer / 1000) + 's', 45, height - 10); this.homeButton.display(); this.restartButton.display(); } handleButton() { if (this.homeButton.isMouseOver()) { currentPage = new StartPage(); } else if (this.restartButton.isMouseOver()) { this.handleRestartButton(); } } handleKeyPress() { // nothing to do } handleRestartButton() { // reset variables timer = 60000; score = 0; balls = []; nextBallColor = random(bubbleColors); shooter.setColor(nextBallColor); shooter = new Shooter(width / 2, height - 50, nextBallColor); shooter.setCanShoot(true); setAutoShootTimeout(); } endGame() { console.log("Game Over"); currentPage = new GameOverPage(score); } reset() { // reset variables timer = 60000; score = 0; balls = []; nextBallColor = random(bubbleColors); shooter.setColor(random(bubbleColors)); shooter = new Shooter(width / 2, height - 50, nextBallColor); shooter.setCanShoot(true); setAutoShootTimeout(); } }
gameoverPage:
class GameOverPage { constructor(score) { this.score = score; this.playerName = ''; this.submitButton = new Button(354, height - 75, 'Submit', 180, 70); this.isSubmitting = false; this.minNameLength = 4; } display() { clear(); background('black'); stroke('white'); image(gameOverImage, 0, 0, 600, 700); textSize(60); textAlign(CENTER, CENTER); text(this.score, 160, 590); // max number of characters for player name: 10 this.playerName = this.playerName.substring(0, 10); textSize(32); text(this.playerName, width / 2, 405); this.submitButton.display(); } handleButton() { if (this.submitButton.isMouseOver()) { this.isSubmitting = true; // validity check for player name if (this.isNameValid()) { leaderboard.addScore(this.playerName, this.score); currentPage = leaderboard; } else { console.log("Invalid player name"); } } } isNameValid() { // validity check: length / taken or not return ( this.playerName.length >= this.minNameLength && this.playerName.length <= 10 && !leaderboard.isNameTaken(this.playerName) ); } handleKeyPress() { if (keyCode === BACKSPACE && keyIsPressed) { this.playerName = this.playerName.slice(0, -1); keyCode = -1; } } keyTyped() { if (keyCode >= 65 && keyCode <= 90 && this.playerName.length < 10) { this.playerName += key; } } }
leaderBoard:
class Leaderboard { constructor() { this.scores = []; this.replayButton = new Button(390, 660, 'Replay', 105, 50); this.homeButton = new Button(210, 660, 'Home', 105, 50, true); } addScore(playerName, score) { // add score to leaderboard this.scores.push({ playerName, score }); // store all scores if there are fewer than 'maxScores'(=max number of scores it can store) scores // if not, sort and only keep 'maxScores'(=max number of scores it can store) scores if (this.scores.length > this.maxScores) { // sort scores (highest to lowest) this.scores.sort((a, b) => b.score - a.score); this.scores = this.scores.slice(0, this.maxScores); } } isNameTaken(playerName) { // check if another player took the name (has the same name as typed name) return this.scores.some(entry => entry.playerName === playerName && entry.playerName.length === playerName.length); } display() { clear(); background('black'); stroke('white'); image(leaderBoardImage, 0, 0, 600, 700); // sort scores (highest to lowest) this.scores.sort((a, b) => b.score - a.score); //needed? isn't it already sorted? // print all players + scores strokeWeight(1); for (let i = 0; i < this.scores.length; i++) { const entry = this.scores[i]; let textSizeValue = 22; // default text size (ranks 3, 4, 5) let text_x; let text_y; // set x, y coordinates & text size for each ranker if (i === 0) { textSizeValue = 36; text_x = 90; text_y = 205; } else if (i === 1) { textSizeValue = 28; text_x = 110; text_y = 305; } else if (i == 2) { text_x = 135; text_y = 405; } else if (i == 3) { text_x = 135; text_y = 495; } else if (i == 4) { text_x = 135; text_y = 580; } textSize(textSizeValue); fill('white'); // print player + score textAlign(LEFT, CENTER); text(`${entry.playerName}`, text_x, text_y); textAlign(RIGHT, CENTER); text(`${entry.score}`, width - text_x, text_y); } this.replayButton.display(); this.homeButton.display(); } handleButton() { if (this.replayButton.isMouseOver()) { // reset variables timer = 60000; score = 0; balls = []; shooter.setCanShoot(true); setAutoShootTimeout(); currentPage = new GamePage(); } else if (this.homeButton.isMouseOver()) { // reset variables timer = 60000; score = 0; balls = []; nextBallColor = random(bubbleColors); shooter.setColor(random(bubbleColors)); shooter = new Shooter(width / 2, height - 50, nextBallColor); shooter.setCanShoot(true); setAutoShootTimeout(); currentPage = new StartPage(); } } handleKeyPress() { // nothing to do } }
reading serial from arduino:
function readSerial(data) { //////////////////////////////////// //READ FROM ARDUINO HERE //////////////////////////////////// if (data != null) { //console.log(data); let fromArduino = split(trim(data), ","); if (fromArduino.length == 2) { potValue = int(fromArduino[0]); forceValue = int(fromArduino[1]); if (currentPage instanceof GamePage){ const previousBallMoving = balls.length === 0 || !balls[balls.length - 1].isMoving(); if (forceValue == 1 && shooter.canShoot() && previousBallMoving) { if (shooter.canShoot()) console.log("shooting through force sensor"); let ball = new Ball(shooter.x, shooter.y, shooter.angle, nextBallColor); balls.push(ball); nextBallColor = random(bubbleColors); shooter.setColor(nextBallColor); shooter.setCanShoot(true); setAutoShootTimeout(); } } } } }
class ball:
class Ball { constructor(x, y, angle, color) { this.x = x; this.y = y; this.diameter = 50; this.radius = this.diameter / 2; this.speed = 10; this.angle = angle; this.color = color; this.stopped = false; this.popping = false; this.popTimer = 0; // timer for delayed popping // value to check if it's newly shot ball or not // 0 means it's a new ball that's being shot / moving // 1 means it's a ball that was shot before this.shot = 0; } update() { if (!this.stopped && !this.popping) { this.x += cos(this.angle) * this.speed; this.y += sin(this.angle) * this.speed; } if (this.shot === 0) { for (let j = 0; j < balls.length; j++) { if (this !== balls[j] && this.intersects(balls[j])) { // ball hit another ball this.shot = 1; this.stop(); break; } } if (this.y - this.radius <= 100) { // ball hit the top border this.shot = 1; this.stop(); } } // update pop timer if (this.popTimer > 0) { this.popTimer -= deltaTime; if (this.popTimer <= 0) { this.pop(); shooter.canShootFlag = 'true'; } } } display() { fill(this.color); ellipse(this.x, this.y, this.diameter, this.diameter); } stop() { this.stopped = true; if (this.color === 'black') { // black ball: bonus ==> pop regarldess of colour for (let j = 0; j < balls.length; j++) { if (this !== balls[j] && this.intersects(balls[j]) && !balls[j].isPopping()) { balls[j].delayedPop(); this.delayedPop(); } } } } delayedPop() { this.popTimer = popDelay; this.popping = true; } pop() { this.x = -100; // moving it off screen this.y = -100; this.stopped = true; score += 1; } bounceOffWall() { this.angle = PI - this.angle; } intersects(otherBall) { // ball intersection must consider the strokewieght of the balls //otherwise, the balls are drawn in a seemingly overlapping way let thisEffectiveRadius = this.radius + this.diameter * 0.05; let otherEffectiveRadius = otherBall.radius + otherBall.diameter * 0.05; // Adjust the factor based on stroke weight let distance = dist(this.x, this.y, otherBall.x, otherBall.y); let minDistance = thisEffectiveRadius + otherEffectiveRadius; return distance < minDistance; } isMoving() { return !this.stopped && !this.popping; } isPopping() { return this.popping; } }
Below is the project:
Even without the console, the game works with left and right arrow keys and ENTER keys.
Below is a video of players trying the game:
Parts I’m Proud Of:
I think for this project, there was a lot of attention to detail. I had to take care of minor things like
– the check of balls hitting each other considering the stroke weight for non overlapping display of balls
– leaderboard keyboard input (had lots of errors)
– leaderboard name validity check (min/max length, duplicate)
– left/right arrow keys for shooter angle and enter key for shooter (for back up in case arduino is not connected etc)
– etc
Because the game was interactive, I though that it had to make sense and smooth in order for the player to not be distracted.
I also think the features of screaming and punching added a kick to the game. This is a fairly simple retro game and needed additional features to make it more fun and interactive. Last time, I used the screaming aspect but wasn’t the perfect use. I think this game gave good reason for players to scream. Also, I believe the punching (more of hitting the table) part made sense- hitting the table to shoot seems intuitive and understandable for the player.
Improvements / Future Expansions:
I think it’d be really nice if I can develop it to make it like the real bubble shooter. Right now, when I shoot a ball and hit another ball of the same colour, it only pops the ball it hit and the ball it shot. In original bubble shooter, all the connected balls with the same colours pop, and if certain parts are totally disconnected, they fall down all together.
This part wasn’t implemented in the project and I think this addition would be a nice add.