Hourglass

Tags: large, artistic, bext, simulation

This visualization program has a rough physics engine that simulates sand falling through the small aperture of an hourglass. The sand piles up in the bottom half of the hourglass; then the hourglass is turned over so the process repeats.

The hourglass program implements a rudimentary physics engine. A physics engine is software that simulates physical objects falling under gravity, colliding with each other, and moving according to the laws of physics. You’ll find physics engines used in video games, computer animation, and scientific simulations. On lines 91 to 102, each “grain” of sand checks if the space beneath it is empty and moves down if it is. Otherwise, it checks if it can move down and to the left (lines 104 to 112) or down and to the right (lines 114 to 122). Of course, there is much more to kinematics, the branch of classical physics that deals with the motion of macroscopic objects, than this. However, you don’t need a degree in physics to make a primitive simulation of sand in an hourglass that is enjoyable to look at.

hourglass.py
  1"""Hourglass, by Al Sweigart al@inventwithpython.com
  2An animation of an hourglass with falling sand. Press Ctrl-C to stop.
  3This code is available at https://nostarch.com/big-book-small-python-programming
  4Tags: large, artistic, bext, simulation"""
  5
  6import random, sys, time
  7
  8try:
  9    import bext
 10except ImportError:
 11    print('This program requires the bext module, which you')
 12    print('can install by following the instructions at')
 13    print('https://pypi.org/project/Bext/')
 14    sys.exit()
 15
 16# Set up the constants:
 17PAUSE_LENGTH = 0.2  # (!) Try changing this to 0.0 or 1.0.
 18# (!) Try changing this to any number between 0 and 100:
 19WIDE_FALL_CHANCE = 50
 20
 21SCREEN_WIDTH = 79
 22SCREEN_HEIGHT = 25
 23X = 0  # The index of X values in an (x, y) tuple is 0.
 24Y = 1  # The index of Y values in an (x, y) tuple is 1.
 25SAND = chr(9617)
 26WALL = chr(9608)
 27
 28# Set up the walls of the hour glass:
 29HOURGLASS = set()  # Has (x, y) tuples for where hourglass walls are.
 30# (!) Try commenting out some HOURGLASS.add() lines to erase walls:
 31for i in range(18, 37):
 32    HOURGLASS.add((i, 1))  # Add walls for the top cap of the hourglass.
 33    HOURGLASS.add((i, 23))  # Add walls for the bottom cap.
 34for i in range(1, 5):
 35    HOURGLASS.add((18, i))  # Add walls for the top left straight wall.
 36    HOURGLASS.add((36, i))  # Add walls for the top right straight wall.
 37    HOURGLASS.add((18, i + 19))  # Add walls for the bottom left.
 38    HOURGLASS.add((36, i + 19))  # Add walls for the bottom right.
 39for i in range(8):
 40    HOURGLASS.add((19 + i, 5 + i))  # Add the top left slanted wall.
 41    HOURGLASS.add((35 - i, 5 + i))  # Add the top right slanted wall.
 42    HOURGLASS.add((25 - i, 13 + i))  # Add the bottom left slanted wall.
 43    HOURGLASS.add((29 + i, 13 + i))  # Add the bottom right slanted wall.
 44
 45# Set up the initial sand at the top of the hourglass:
 46INITIAL_SAND = set()
 47for y in range(8):
 48    for x in range(19 + y, 36 - y):
 49        INITIAL_SAND.add((x, y + 4))
 50
 51
 52def main():
 53    bext.fg('yellow')
 54    bext.clear()
 55
 56    # Draw the quit message:
 57    bext.goto(0, 0)
 58    print('Ctrl-C to quit.', end='')
 59
 60    # Display the walls of the hourglass:
 61    for wall in HOURGLASS:
 62        bext.goto(wall[X], wall[Y])
 63        print(WALL, end='')
 64
 65    while True:  # Main program loop.
 66        allSand = list(INITIAL_SAND)
 67
 68        # Draw the initial sand:
 69        for sand in allSand:
 70            bext.goto(sand[X], sand[Y])
 71            print(SAND, end='')
 72
 73        runHourglassSimulation(allSand)
 74
 75
 76def runHourglassSimulation(allSand):
 77    """Keep running the sand falling simulation until the sand stops
 78    moving."""
 79    while True:  # Keep looping until sand has run out.
 80        random.shuffle(allSand)  # Random order of grain simulation.
 81
 82        sandMovedOnThisStep = False
 83        for i, sand in enumerate(allSand):
 84            if sand[Y] == SCREEN_HEIGHT - 1:
 85                # Sand is on the very bottom, so it won't move:
 86                continue
 87
 88            # If nothing is under this sand, move it down:
 89            noSandBelow = (sand[X], sand[Y] + 1) not in allSand
 90            noWallBelow = (sand[X], sand[Y] + 1) not in HOURGLASS
 91            canFallDown = noSandBelow and noWallBelow
 92
 93            if canFallDown:
 94                # Draw the sand in its new position down one space:
 95                bext.goto(sand[X], sand[Y])
 96                print(' ', end='')  # Clear the old position.
 97                bext.goto(sand[X], sand[Y] + 1)
 98                print(SAND, end='')
 99
100                # Set the sand in its new position down one space:
101                allSand[i] = (sand[X], sand[Y] + 1)
102                sandMovedOnThisStep = True
103            else:
104                # Check if the sand can fall to the left:
105                belowLeft = (sand[X] - 1, sand[Y] + 1)
106                noSandBelowLeft = belowLeft not in allSand
107                noWallBelowLeft = belowLeft not in HOURGLASS
108                left = (sand[X] - 1, sand[Y])
109                noWallLeft = left not in HOURGLASS
110                notOnLeftEdge = sand[X] > 0
111                canFallLeft = (noSandBelowLeft and noWallBelowLeft
112                    and noWallLeft and notOnLeftEdge)
113
114                # Check if the sand can fall to the right:
115                belowRight = (sand[X] + 1, sand[Y] + 1)
116                noSandBelowRight = belowRight not in allSand
117                noWallBelowRight = belowRight not in HOURGLASS
118                right = (sand[X] + 1, sand[Y])
119                noWallRight = right not in HOURGLASS
120                notOnRightEdge = sand[X] < SCREEN_WIDTH - 1
121                canFallRight = (noSandBelowRight and noWallBelowRight
122                    and noWallRight and notOnRightEdge)
123
124                # Set the falling direction:
125                fallingDirection = None
126                if canFallLeft and not canFallRight:
127                    fallingDirection = -1  # Set the sand to fall left.
128                elif not canFallLeft and canFallRight:
129                    fallingDirection = 1  # Set the sand to fall right.
130                elif canFallLeft and canFallRight:
131                    # Both are possible, so randomly set it:
132                    fallingDirection = random.choice((-1, 1))
133
134                # Check if the sand can "far" fall two spaces to
135                # the left or right instead of just one space:
136                if random.random() * 100 <= WIDE_FALL_CHANCE:
137                    belowTwoLeft = (sand[X] - 2, sand[Y] + 1)
138                    noSandBelowTwoLeft = belowTwoLeft not in allSand
139                    noWallBelowTwoLeft = belowTwoLeft not in HOURGLASS
140                    notOnSecondToLeftEdge = sand[X] > 1
141                    canFallTwoLeft = (canFallLeft and noSandBelowTwoLeft
142                        and noWallBelowTwoLeft and notOnSecondToLeftEdge)
143
144                    belowTwoRight = (sand[X] + 2, sand[Y] + 1)
145                    noSandBelowTwoRight = belowTwoRight not in allSand
146                    noWallBelowTwoRight = belowTwoRight not in HOURGLASS
147                    notOnSecondToRightEdge = sand[X] < SCREEN_WIDTH - 2
148                    canFallTwoRight = (canFallRight
149                        and noSandBelowTwoRight and noWallBelowTwoRight
150                        and notOnSecondToRightEdge)
151
152                    if canFallTwoLeft and not canFallTwoRight:
153                        fallingDirection = -2
154                    elif not canFallTwoLeft and canFallTwoRight:
155                        fallingDirection = 2
156                    elif canFallTwoLeft and canFallTwoRight:
157                        fallingDirection = random.choice((-2, 2))
158
159                if fallingDirection == None:
160                    # This sand can't fall, so move on.
161                    continue
162
163                # Draw the sand in its new position:
164                bext.goto(sand[X], sand[Y])
165                print(' ', end='')  # Erase old sand.
166                bext.goto(sand[X] + fallingDirection, sand[Y] + 1)
167                print(SAND, end='')  # Draw new sand.
168
169                # Move the grain of sand to its new position:
170                allSand[i] = (sand[X] + fallingDirection, sand[Y] + 1)
171                sandMovedOnThisStep = True
172
173        sys.stdout.flush()  # (Required for bext-using programs.)
174        time.sleep(PAUSE_LENGTH)  # Pause after this
175
176        # If no sand has moved on this step, reset the hourglass:
177        if not sandMovedOnThisStep:
178            time.sleep(2)
179            # Erase all of the sand:
180            for sand in allSand:
181                bext.goto(sand[X], sand[Y])
182                print(' ', end='')
183            break  # Break out of main simulation loop.
184
185
186# If this program was run (instead of imported), run the game:
187if __name__ == '__main__':
188    try:
189        main()
190    except KeyboardInterrupt:
191        sys.exit()  # When Ctrl-C is pressed, end the program.

https://inventwithpython.com/bigbookpython/project36.html