Fullscreen link: https://editor.p5js.org/fhgggtyf/full/_GaqN8_Sa
Overall Concepts
The concepts didn’t change much compared to what I thought of when planning. However, instead of staring with randomly generated maps, I chose to use the original map for the first few levels so that the difficulty won’t get too insane at the beginning. Then, after the user is familiar with the game, they will encounter randomly generated maps which are insanely difficult. The main gameplay is the same with the original Pac-Man version, but the success condition for finishing a level no longer is to eat all pellets but also reaching the portals to get to the next level. Each level has a theme which is rotated in a pool of three: fire, wood, and water. Each theme has its own sets of environment and music. This is because I do not want this game to only feel like a game to the user, but feel like a real adventure in various landscapes. The player also gets a record of their final score after their 3 health is depleted. The four ghosts each have a different logic system of pursuing the player, and the mode changes of the ghosts are coherent with the original game. As levels increase, scatter mode and chase mode ratios will change to make difficulties higher.
Implementation
The game is mostly separated into 3 modules: Game, Player, Ghosts. It could be divided to make the structure clearer but since its not such a big project I decided to keep it simple.
The game module is responsible for controlling how the game progresses. There are 3 game states, which are start, in game, and game over. In the in game state, it is responsible for the game interactions, level states, and sending signals to the ghosts making them go to different modes. There should be a game effect controller to do this but I integrated the elements into the game module instead. Basically, this module controls all in-game state changes and the program state changes.
the player module is responsible for taking in inputs and converting it into actions of the player object. it also contains stats of the player.
The ghosts module is used for calculating the actions of the ghosts. each ghost have its own rules in pursuit of the player. Also the ghosts have different states, which would effect their destination points. Overall, All ghosts have the same rules pursuing a certain destination point. However, different ghosts usually have different destination points due to their unique behaviors. Therefore I have subclasses inheriting the Ghosts class so they can determine their own destinations. These squares refers to their different destination points based on their behaviors. The squares in the following image stands for the destinations of each of the ghosts.
The code is shown below.
class Ghosts{
constructor(){
this.position=[1,1];
this.img=blinkyImage;
this.vulnerable=false;
this.addPointFlag=false;
this.absPos=[round(this.position[0]),round(this.position[1])];
this.prevAbsPos=this.absPos;
this.state=0;//prepare state
this.dir=[1,0];//no direction
this.speed=ghostSpeed[game.gameLevel];
this.dest=[0,0];
this.next=[0,0];
this.sur=[[this.absPos[0],this.absPos[1]-1],
[this.absPos[0]+1,this.absPos[1]],
[this.absPos[0],this.absPos[1]+1],
[this.absPos[0]-1,this.absPos[1]]];
this.choices=[];
}
checkState(){ // Check ghost's state to determin action
if(this.state==0&&this.absPos[1]>11){
this.resetMode();
}
else if(this.state==9){
if(this.absPos[0]==13&&this.absPos[1]==15){
this.state=0;
this.addPointFlag=false;
}
}
else{
if(game.frightened && game.frightenedTimer<=7){
this.frightenedMode();
}
else{
this.vulnerable=false;
game.frightened=false;
game.frightenedTimer=0;
if(game.cycleTimer/(int(chaseTime[game.gameLevel])+int(scatterTime[game.gameLevel]))<=5 && game.cycleTimer%(int(chaseTime[game.gameLevel])+int(scatterTime[game.gameLevel]))<scatterTime[game.gameLevel]){
this.scatterMode();
}
else{
this.chaseMode();
}
}
}
}
defeatedMode(){
this.state=9;
this.img=eyesImage;
this.dest=[13,15];
}
resetMode(){
this.dest=[13,11];
}
checkMoved(){ // check if the ghost moved a whole block, if moved calculate new destination
this.absPos=[round(this.position[0]),round(this.position[1])];
this.sur=[[this.absPos[0],this.absPos[1]-1],
[this.absPos[0]+1,this.absPos[1]],
[this.absPos[0],this.absPos[1]+1],
[this.absPos[0]-1,this.absPos[1]]];
if(this.absPos[0]!=this.prevAbsPos[0] || this.absPos[1]!=this.prevAbsPos[1]){
this.calcMovement();
this.prevAbsPos=this.absPos;
}
}
calcMovement(){ // calculate new destination and how to get there
this.choices=[];
this.sur.forEach(element => {
if((element[0]!=this.prevAbsPos[0] || element[1]!=this.prevAbsPos[1]) && mapData[game.mapNum][element[1]][element[0]]!=1){
if((this.state != 0 && this.state != 9)&& mapData[game.mapNum][element[1]][element[0]]==3){
}
else{
this.choices.push(element);
}
}
});
if(this.choices.length==0){
if(this.absPos[0]==1 && this.absPos[1]==14){
this.position=[26,14];
this.absPos=[round(this.position[0]),round(this.position[1])];
this.choices.push([25,14]);
}
else if(this.absPos[0]==26 && this.absPos[1]==14){
this.position=[1,14];
this.absPos=[round(this.position[0]),round(this.position[1])];
this.choices.push([2,14]);
}
}
let closest = Infinity;
this.choices.forEach(element => {
let difference = sq(element[0]-this.dest[0])+sq(element[1]-this.dest[1]);
// Check if the current element is closer than the previous closest element
if (difference < closest) {
closest = difference;
this.next = element;
}
});
this.dir=[this.next[0]-this.absPos[0],this.next[1]-this.absPos[1]];
}
moveToNext(){ // move
if(this.dir[0]!=0){
this.position[0]+=this.dir[0]*this.speed*deltaTime/1000;
this.position[1]=this.absPos[1];
}
else{
this.position[1]+=this.dir[1]*this.speed*deltaTime/1000;
this.position[0]=this.absPos[0];
}
}
frightenedMode(){
this.vulnerable=true;
this.img=vulImage;
if(this.choices.length>1){
this.dest=this.choices[floor(random(this.choices.length))];
}
}
}
class Blinky extends Ghosts{
constructor(state,dest,position,img){
super(state,dest,position,img);
this.position=[13.5,11]
this.img=blinkyImage;
}
scatterMode(){ // Scatter mode determine destination
if(this.state!=2){
this.dir[0]=-this.dir[0];
this.dir[1]=-this.dir[1];
this.state=2;
}
this.dest=[27,0];
}
chaseMode(){ // Chase mode determine destination
if(this.state!=1){
this.dir[0]=-this.dir[0];
this.dir[1]=-this.dir[1];
this.state=1;
}
this.dest=player.position;
}
display(){
if(this.state!=9 && this.vulnerable==false){
this.img=blinkyImage;
}
fill("red");
// rect(this.dest[0]*40,this.dest[1]*40,40,40);
image(this.img,this.position[0]*40,this.position[1]*40,40,40);
fill(0); // Set the text fill color to black
}
}
In the code above I only shown one subclass so that it won’t be too long. The basic concept of the subclasses are similar. I am pretty proud of this part because the structure is clear and it made the development progress so much easier.
Areas for Improvement
Some areas of improvements could be the aesthetics of the different scenes. I planned to do a pixelated low-poly tile-map styled map design but it turned out to look very unnatural and dirty. If I have more knowledge in graphic design I might be able to do better. Another thing is that the program has a few minor rule bugs that may allow the user to gain points incredibly fast (with a great risk). Maybe I’ll fix them in the future. Also, the random maps may cause the ghosts to behave abnormally because of the limitations of a 43-year-old algorithm. It could also be refined. Also a user login system could be applied to store people’s personal bests online. Apart of random maps, I could also add one way areas or blockages that would slow the player down, etc. But that would require refines in the ghosts’ AI, which I didn’t have time to do in this project.