|
| 1 | +import board |
| 2 | +import busio |
| 3 | +import math |
| 4 | +import random |
| 5 | + |
| 6 | +import adafruit_lsm303 |
| 7 | +import adafruit_dotstar |
| 8 | + |
| 9 | +N_GRAINS = 10 # Number of grains of sand |
| 10 | +WIDTH = 12 # Display width in pixels |
| 11 | +HEIGHT = 6 # Display height in pixels |
| 12 | +NUMBER_PIXELS = WIDTH * HEIGHT |
| 13 | +MAX_FPS = 45 # Maximum redraw rate, frames/second |
| 14 | +GRAIN_COLOR = (64, 64, 64) |
| 15 | +MAX_X = WIDTH * 256 - 1 |
| 16 | +MAX_Y = HEIGHT * 256 - 1 |
| 17 | + |
| 18 | +class Grain: |
| 19 | + def __init__(self): |
| 20 | + x = 0 |
| 21 | + y = 0 |
| 22 | + vx = 0 |
| 23 | + vy = 0 |
| 24 | + |
| 25 | + |
| 26 | +grains = [Grain() for _ in range(N_GRAINS)] |
| 27 | +i2c = busio.I2C(board.SCL, board.SDA) |
| 28 | +sensor = adafruit_lsm303.LSM303(i2c) |
| 29 | +wing = adafruit_dotstar.DotStar(board.D13, board.D11, WIDTH * HEIGHT, 1.0, False) |
| 30 | + |
| 31 | +oldidx = 0 |
| 32 | +newidx = 0 |
| 33 | +delta = 0 |
| 34 | +newx = 0 |
| 35 | +newy = 0 |
| 36 | + |
| 37 | +occupied_bits = [False for _ in range(WIDTH * HEIGHT)] |
| 38 | + |
| 39 | +def index_of_xy(x, y): |
| 40 | + return (y >> 8) * WIDTH + (x >> 8) |
| 41 | + |
| 42 | +def already_present(i, x, y): |
| 43 | + for j in range(i): |
| 44 | + if x == grains[j].x or y == grains[j].y: |
| 45 | + return True |
| 46 | + return False |
| 47 | + |
| 48 | +for g in grains: |
| 49 | + placed = False |
| 50 | + while not placed: |
| 51 | + g.x = random.randint(0, WIDTH * 256 - 1) |
| 52 | + g.y = random.randint(0, HEIGHT * 256 - 1) |
| 53 | + placed = not occupied_bits[index_of_xy(g.x, g.y)] |
| 54 | + occupied_bits[index_of_xy(g.x, g.y)] = True |
| 55 | + g.vx = 0 |
| 56 | + g.vy = 0 |
| 57 | + |
| 58 | +while True: |
| 59 | + # Display frame rendered on prior pass. It's done immediately after the |
| 60 | + # FPS sync (rather than after rendering) for consistent animation timing. |
| 61 | + |
| 62 | + for i in range(NUMBER_PIXELS): |
| 63 | + wing[i] = GRAIN_COLOR if occupied_bits[i] else (0,0,0) |
| 64 | + wing.show() |
| 65 | + |
| 66 | + # Read accelerometer... |
| 67 | + f_x, f_y, f_z = sensor.raw_accelerometer |
| 68 | + ax = f_x >> 8 # Transform accelerometer axes |
| 69 | + ay = f_y >> 8 # to grain coordinate space |
| 70 | + az = abs(f_z) >> 11 # Random motion factor |
| 71 | + az = 1 if (az >= 3) else (4 - az) # Clip & invert |
| 72 | + ax -= az # Subtract motion factor from X, Y |
| 73 | + ay -= az |
| 74 | + az2 = (az << 1) + 1 # Range of random motion to add back in |
| 75 | + |
| 76 | + # ...and apply 2D accel vector to grain velocities... |
| 77 | + v2 = 0 # Velocity squared |
| 78 | + v = 0.0 # Absolute velociy |
| 79 | + for g in grains: |
| 80 | + g.vx += ax + random.randint(0, az2) # A little randomness makes |
| 81 | + g.vy += ay + random.randint(0, az2) # tall stacks topple better! |
| 82 | + |
| 83 | + # Terminal velocity (in any direction) is 256 units -- equal to |
| 84 | + # 1 pixel -- which keeps moving grains from passing through each other |
| 85 | + # and other such mayhem. Though it takes some extra math, velocity is |
| 86 | + # clipped as a 2D vector (not separately-limited X & Y) so that |
| 87 | + # diagonal movement isn't faster |
| 88 | + |
| 89 | + v2 = g.vx * g.vx + g.vy * g.vy |
| 90 | + if v2 > 65536: # If v^2 > 65536, then v > 256 |
| 91 | + v = math.floor(math.sqrt(v2)) # Velocity vector magnitude |
| 92 | + g.vx = (g.vx // v) << 8 # Maintain heading |
| 93 | + g.vy = (g.vy // v) << 8 # Limit magnitude |
| 94 | + |
| 95 | + # ...then update position of each grain, one at a time, checking for |
| 96 | + # collisions and having them react. This really seems like it shouldn't |
| 97 | + # work, as only one grain is considered at a time while the rest are |
| 98 | + # regarded as stationary. Yet this naive algorithm, taking many not- |
| 99 | + # technically-quite-correct steps, and repeated quickly enough, |
| 100 | + # visually integrates into something that somewhat resembles physics. |
| 101 | + # (I'd initially tried implementing this as a bunch of concurrent and |
| 102 | + # "realistic" elastic collisions among circular grains, but the |
| 103 | + # calculations and volument of code quickly got out of hand for both |
| 104 | + # the tiny 8-bit AVR microcontroller and my tiny dinosaur brain.) |
| 105 | + |
| 106 | + for g in grains: |
| 107 | + newx = g.x + g.vx # New position in grain space |
| 108 | + newy = g.y + g.vy |
| 109 | + if newx > MAX_X: # If grain would go out of bounds |
| 110 | + newx = MAX_X # keep it inside, and |
| 111 | + g.vx //= -2 # give a slight bounce off the wall |
| 112 | + elif newx < 0: |
| 113 | + newx = 0 |
| 114 | + g.vx //= -2 |
| 115 | + if newy > MAX_Y: |
| 116 | + newy = MAX_Y |
| 117 | + g.vy //= -2 |
| 118 | + elif newy < 0: |
| 119 | + newy = 0 |
| 120 | + g.vy //= -2 |
| 121 | + |
| 122 | + oldidx = index_of_xy(g.x, g.y) # prior pixel |
| 123 | + newidx = index_of_xy(newx, newy) # new pixel |
| 124 | + if oldidx != newidx and occupied_bits[newidx]: # If grain is moving to a new pixel... but if that pixel is already occupied... |
| 125 | + delta = abs(newidx - oldidx) # What direction when blocked? |
| 126 | + if delta == 1: # 1 pixel left or right |
| 127 | + newx = g.x # cancel x motion |
| 128 | + g.vx //= -2 # and bounce X velocity (Y is ok) |
| 129 | + newidx = oldidx # no pixel change |
| 130 | + elif delta == WIDTH: # 1 pixel up or down |
| 131 | + newy = g.y # cancel Y motion |
| 132 | + g.vy //= -2 # and bounce Y velocity (X is ok) |
| 133 | + newidx = oldidx # no pixel change |
| 134 | + else: # Diagonal intersection is more tricky... |
| 135 | + # Try skidding along just one axis of motion if possible (start w/ |
| 136 | + # faster axis). Because we've already established that diagonal |
| 137 | + # (both-axis) motion is occurring, moving on either axis alone WILL |
| 138 | + # change the pixel index, no need to check that again. |
| 139 | + if abs(g.vx) > abs(g.vy): # x axis is faster |
| 140 | + newidx = index_of_xy(newx, g.y) |
| 141 | + if not occupied_bits[newidx]: # that pixel is free, take it! But... |
| 142 | + newy = g.y # cancel Y motion |
| 143 | + g.vy //= -2 # and bounce Y velocity |
| 144 | + else: # X pixel is taken, so try Y... |
| 145 | + newidx = index_of_xy(g.x, newy) |
| 146 | + if not occupied_bits[newidx]: # Pixel is free, take it, but first... |
| 147 | + newx = g.x # Cancel X motion |
| 148 | + g.vx //= -2 # Bounce X velocity |
| 149 | + else: # both spots are occupied |
| 150 | + newx = g.x # Cancel X & Y motion |
| 151 | + newy = g.y |
| 152 | + g.vx //= -2 # Bounce X & Y velocity |
| 153 | + g.vy //= -2 |
| 154 | + newidx = oldidx # Not moving |
| 155 | + else: # y axis is faster. start there |
| 156 | + newidx = index_of_xy(g.x, newy) |
| 157 | + if not occupied_bits[newidx]: # Pixel's free! Take it! But... |
| 158 | + newx = g.x # Cancel X motion |
| 159 | + g.vx //= -2 # Bounce X velocity |
| 160 | + else: # Y pixel is taken, so try X... |
| 161 | + newidx = index_of_xy(newx, g.y) |
| 162 | + if not occupied_bits[newidx]: # Pixel is free, take it, but first... |
| 163 | + newy = g.y # cancel Y motion |
| 164 | + g.vy //= -2 # and bounce Y velocity |
| 165 | + else: # both spots are occupied |
| 166 | + newx = g.x # Cancel X & Y motion |
| 167 | + newy = g.y |
| 168 | + g.vx //= -2 # Bounce X & Y velocity |
| 169 | + g.vy //= -2 |
| 170 | + newidx = oldidx # Not moving |
| 171 | + occupied_bits[oldidx] = False |
| 172 | + occupied_bits[newidx] = True |
| 173 | + g.x = newx |
| 174 | + g.y = newy |
0 commit comments