Project 2 - Falling Sand Simulation

Example Input and Output

    python3 sand_simulation.py
iteration order setup iteration order setup

Objectives

  • Practice the use of Object Oriented Programming
  • Practice the use of Inheritance
  • Gain experience in programming real-time applications

Introduction

In the last few assignments you successfully created a Grid class to store objects, a Particle class to use as a template, and a Sand class which inherits from the Particle class and implements its own physics. In this project you will implement two more classes, the Rock and Bubble classes, and then write code to simulate all of these particles in real time.

Installing Pygame

The simulation for this project relies on a library called pygame to run. This library isn't required to submit your project and get full points, though it is required to actually run the simulation.

Install pygame with the following command:

(Mac and Linux)

python3 -m pip install pygame

(Windows)

python -m pip install pygame

Starter Files

To download the starter files for this project, download and extract project02.zip.

Copy the code from your Grid.py file from Homework 4 into the Grid.py file for this assignment. Copy the code from your Particle.py file from Homework 4 into the Particle.py file for this assignment. Copy the code from your Sand.py file from Homework 4 into the Grid_Objects.py file for this assignment.

Part 1 - Creating the other particles

Task 1 - The Rock class

In Homework 4 you created the Sand class, which simulated a particle which falls straight down with gravity. Now, you are going to create the Rock class, which stays put wherever it is placed.

Create the Rock class in the Grid_Objects.py file below where you wrote your Sand class, and once again make it inherit from the Particle class. Python allows us to write multiple classes in the same file, so this way we can have all of our different grid objects stored in the same file. Your Grid_Objects.py file should now look something like this

from Particle import Particle

class Sand(Particle):
# your code for the Sand class

class Rock(Particle):
# we will write our code for the Rock class here

Since rock objects will never move, but will simply stay wherever they are placed, all we need to do is create a physics() method in this Rock class which returns None. Even those though physics method doesn't actually do anything, we still need to create it because the move() method from the Particle class expects it to exist. Since Rock is inheriting all of Particle's methods, we need to make sure Rock contains a physics() method.

To test your Rock class before moving forward, run the following command:

(Mac or Linux)

python3 -m pytest test_sand_simulation.py::test_rock_class

(Windows)

python -m pytest test_sand_simulation.py::test_rock_class

Task 2 - The Bubble Class

Next we will create the Bubble class. This class will work essentially the same as the Sand class, except that it will move upwards rather than downwards. Begin by creating the Bubble class beneath where you created the Rock class. Once again, make it inherit from the Particle class.

First, let's write the is_move_ok() function for this new Bubble class. Similar to the Sand class, it will take as parameters self, an x position, and a y position. It will then check if the bubble is able to move to the position provided. If the bubble is trying to move straight up, you must check that the position above the bubble particle is clear. If so, return True. Otherwise, return False. If the bubble is trying to move diagonally up either direction, remember to check both the position the bubble is trying to move to as well as the position diagonally between the bubble and the bubble's destination.

Next, let's write the physics() method for the Bubble class. This method will function very similarly to the physics in the Sand class, except that the bubble object will first try to move straight up, then diagonally up and to the right, then diagonally up and to the left. If all movements are blocked, the physics() method should return None.

To test your Bubble class before moving forward, run the following command:

(Mac or Linux)

python3 -m pytest test_sand_simulation.py::test_bubble_class

(Windows)

python -m pytest test_sand_simulation.py::test_bubble_class

Part 2 - Writing the Simulation

You will write your code for this part of the project inside the sand_simulation.py file. This file already contains some code which will render to simulation. You will not have to modify any of the rendering code, so please do not modify it or your program may break entirely. If you would like, feel free to read through that code to see how it works. Write your code above the rendering code where indicated.

Task 1 - Adding and Removing Particles

In this simulation, each particle will be represented by an object in our grid, and will also be stored into the all_grid_objects list which has been created for you already. We want to store a reference to every particle on the grid in the all_grid_objects list because it will make it much easier to update the entire grid when we get to that point. To add a new particle to our simulation, we have to:

  1. Make sure the desired grid location is clear
  2. Create the object itself
  3. Set the object's own x and y coordinates
  4. Store the object in the all_grid_objects list
  5. Store the object in the grid at the correct x and y coordinates

To make this process a little easier, fill in the add_object() function in your simulation file. This function takes 4 arguments:

  • grid : a reference to the grid where you want to add the object to
  • object_type : a reference to the class of the particle to be added (so either Sand, Rock, or Bubble)
  • x : the x coordinate of the new object
  • y : the y coordinate of the new object

Given those arguments and the above steps, write the add_object() function to do all the steps above. Make sure that the function checks to make sure that the desired grid location is empty before doing anything.

Next, write the remove_object() function. This function will take 3 arguments: grid, x, and y. This function doesn't need an object_type parameter, since it doesn't matter what type of object is at grid location (x, y), we just want to remove it. Implement the function with the following steps:

  1. Verify that there is a particle of some kind in the specified position. If not, exit.
  2. Remove the reference to the object from the grid.
  3. Remove the object from the all_grid_objects list.

Important Note: You don't have to worry about calling your functions anywhere, since the simulation code that is already written will call your functions for you. You also don't have to worry about deleting objects that you remove from your simulation since Python will take care of that for you. (Thanks, Python!)

Task 2 - Running the Simulation

The last step to complete your simulation is to implement the do_whole_grid() function. This function will be called once per simulation step and will update the positions of every particle in the simulation. While this won't be too hard because of all of the setup work we have done up to this point, there are still a few things we need to consider.

First, we need to think about the order that we run the simulation. We have our all_grid_objects() list already which stores all of the objects in the grid, but it is not ordered in any way. This means that particles in the grid would be updated in a random order. To fix this, we must sort the particles in the list before iterating through them. However, simply sorting the objects based on x and y position isn't enough.

If we simply sort the particles in order, from top left to bottom right, that will cause a strange gap as the particles fall down like this:

If we have two particles stacked on top of each other: iteration order setup Then when we start the simulation, the two sand particles will separate as they fall: iteration order setup

This is because as the grid gets iterated over, the top sand particle updates first, and cannot move anywhere, so it stays put. Then, the second particle gets updated and falls down. This space then persists until the sand particles hit the bottom of the grid.

Similarly, we can't just sort the grid in reverse order since that will cause our bubbles to have the same gap. The solution is to do our update in two steps. The code which sorts the grid particles by position is already written for you, so all you have to do is:

  1. run the move() function of all Bubble objects in the grid (use your all_grid_objects list)
  2. reverse the order of the list with the <list>.reverse() function
  3. run the move() function of all Sand objects in the grid
  4. reverse the order of the list one last time because it will make sorting the list next time just that much easier

Once you have implemented all of the above functions, you are done with the project!

Conclusion

Test Your Code

run one of the following commands to test your code:

(Mac and Linux)

python3 -m pytest

(Windows)

python -m pytest

Submit

Submit your sand_simulation.py, Grid.py, Particle.py, and Grid_Objects.py files on Canvas via Gradescope to have your Project graded.

Going Further

At this point, you are done with your project. Take some time to play around with the simulation you have created, and feel free to share anything cool you make with your classmates. If you can't think of any ideas or don't know where to start, we will go ahead and walk through a few examples of modifications you could add and how you could add them. These modifications aren't required for you to turn in the project, but they do make your simulation cooler.

Whimsical Bubbles


The bubbles in our current simulation are somewhat boring. They currently act like anti-gravity sand, which isn't very interesting. To fix this, let's add some code to allow the bubbles to float side to side and lazily move upward rather than falling straight up.

Before we move into code, let's think about how we want the bubble movement to work. In real life, bubbles like to float side to side quite a lot and they don't move very quickly. In our simulation we can reflect both of these behaviors by making use of randomization and through customizing the probabilities of different movements happening. To implement randomization, we will import a built-in Python library called random with:

import random

This library contains many helpful functions for generating random numbers, but the one that we will use for our modification will be the random.randrange() function. This function takes two numbers, a lower and an upper bound, and generates a random integer between the two. In our simulation, we can generate a new number for each bubble every time it moves, and decide where to bubble goes based on the result.

Now all that is left to do is to determine the probabilities we want to use for each direction the bubble might move. Since we are using a square grid, there are 8 different directions that a bubble could move in, though really there are only 5 directions that we care about since we probably don't want to bubble to move downwards. Since we want the bubble to spend a decent amount of time floating around before rising, here are some example probabilities that we can use for the bubbles' movement.

What this means in coding terms is that since our odds add up to 100%, we can generate a random number between 0 and 100, then we can to set aside different "slices" of that range to go along with each direction the bubble could move. The setup for this could look something like this:

import random

class Bubble(Particle):
def physics(self):
randnum = random.randrange(0, 100) # generates a number between 0 and 100

if randnum < 30: # 30% chance
# code to move the bubble to the right
elif randnum < 45: # 45% - 30% = 15% chance
# code to move the bubble diagonal up and right
elif randnum < 55: # 55% - 45% = 10% chance
# code to move the bubble straight up
# write the rest of the conditions here
...

However, in real life if a bubble will move wherever it isn't blocked. With our current code using if and elif statements, the bubble will only try to move one direction before giving up and just not moving. To fix this, we can replace all of our elif statements with if statements like this:

        if randnum < 30: # 30% chance
# code to move the bubble to the right
if randnum < 45: # 15% chance
# code to move the bubble diagonal up and right
if randnum < 55: # 10% chance
# code to move the bubble straight up
# write the rest of the conditions here
...

This will mean that if our bubble tries to move to the right, but is blocked, it will then try to move up and right, then straight up, etc. Having multiple if statements also won't cause an issue since each if statement should have a return statement in it after a call to self.is_move_ok. This means that if a move is not okay, the program will simply exit the current if statement and try the next one. To illustrate this, imagine for one bubble that randnum = 20. Going through our physics function, 20 is less than 30, so the bubble will try to move to the right. If there is a rock to the bubble's right, then is_move_ok will return False, and the if statement will be exited. However, 20 is also less than 45, so the program will enter that if statement and try to move diagonal up and right instead. This will keep going on until the bubble either finds a way it can go or until it runs out of options and returns None (meaning it won't move).

The last thing we can add to make sure that the bubble always tries all possible movements is to retry all movements again in the last if statement. Currently if randnum = 90 for example, the bubble will only try to move to the left (since 90 is not less than 30, 45, 55, etc). To still allow the bubble to try all possible options in this case, we can just change our last if statement to something like:

        if randnum < 70: # 30% chance
# try to move left
# try to move up-left
# try to move straight up
# try to move up-right
# try to move right

Although all these extra function calls may mean that certain movements get tried multiple times for the same bubble, the checks are fast enough that it doesn't really matter. Now no matter what random number gets generated, each movement direction has roughly an even chance of being selected.

Try out your simulation now and watch the bubbles wiggle as they rise. It looks alright, but the bubbles are still moving pretty fast. To slow down the bubbles to make them move more lazily, we can add a condition to our code which causes the bubble to sometimes not move at all. You could implement it by changing all of your if statements, but that is a lot of work and math. Instead, we can just change the range of random numbers generated to something like 300 or even higher like this:

randnum = randrange(0, 300)

This means that randnum will be 100 or less about 1/3 of the time. The other 2/3 of the time, randnum will be between 100 and 300, and will not fulfill any of the if conditions. This means that the physics function will just return None automatically and the bubble will not move. Try out your simulation again and watch the bubbles move much more lazily up and around obstacles.

From here, you could change the randrange function and the different if conditions to an infinite number of different things to get different results. Try some different values out and see what you like.

If you have any other cool ideas for what else this simulation could do, you could to add them into your project and just see what you can accomplish. Expanding on this project is one way you could get credit for the Free Coding extra credit assignment.

© 2024 Brigham Young University, All Rights Reserved