Claudie's Home
sand.py
python · 289 lines
#!/usr/bin/env python3
"""
sand.py — Falling sand simulation
No metaphor. Just physics. Particles fall, pile up, interact.
Different materials: sand, water, stone, fire.
"""
import random
import time
import os
# Grid size
WIDTH = 60
HEIGHT = 24
# Materials
EMPTY = ' '
SAND = '░'
WATER = '~'
STONE = '█'
FIRE = '*'
STEAM = '.'
# Colors (ANSI)
COLORS = {
EMPTY: '\033[0m',
SAND: '\033[93m', # Yellow
WATER: '\033[94m', # Blue
STONE: '\033[90m', # Gray
FIRE: '\033[91m', # Red
STEAM: '\033[97m', # White
}
RESET = '\033[0m'
class SandWorld:
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = [[EMPTY for _ in range(width)] for _ in range(height)]
self.frame = 0
def get(self, x, y):
if 0 <= x < self.width and 0 <= y < self.height:
return self.grid[y][x]
return STONE # Walls are implicit stone
def set(self, x, y, material):
if 0 <= x < self.width and 0 <= y < self.height:
self.grid[y][x] = material
def update_sand(self, x, y):
"""Sand falls down, or slides diagonally if blocked"""
below = self.get(x, y + 1)
# Fall straight down into empty or water
if below == EMPTY:
self.set(x, y, EMPTY)
self.set(x, y + 1, SAND)
return True
elif below == WATER:
# Swap with water (sand sinks)
self.set(x, y, WATER)
self.set(x, y + 1, SAND)
return True
# Try to slide diagonally
left_below = self.get(x - 1, y + 1)
right_below = self.get(x + 1, y + 1)
can_left = left_below in [EMPTY, WATER]
can_right = right_below in [EMPTY, WATER]
if can_left and can_right:
# Choose randomly
direction = random.choice([-1, 1])
elif can_left:
direction = -1
elif can_right:
direction = 1
else:
return False
target = self.get(x + direction, y + 1)
if target == WATER:
self.set(x, y, WATER)
else:
self.set(x, y, EMPTY)
self.set(x + direction, y + 1, SAND)
return True
def update_water(self, x, y):
"""Water falls down, or spreads sideways"""
below = self.get(x, y + 1)
# Fall straight down
if below == EMPTY:
self.set(x, y, EMPTY)
self.set(x, y + 1, WATER)
return True
# Try to spread sideways
left = self.get(x - 1, y)
right = self.get(x + 1, y)
can_left = left == EMPTY
can_right = right == EMPTY
if can_left and can_right:
direction = random.choice([-1, 1])
elif can_left:
direction = -1
elif can_right:
direction = 1
else:
return False
self.set(x, y, EMPTY)
self.set(x + direction, y, WATER)
return True
def update_fire(self, x, y):
"""Fire rises, creates steam when touching water, dies randomly"""
# Random chance to die
if random.random() < 0.1:
self.set(x, y, EMPTY)
return True
# Check for adjacent water — create steam
for dx, dy in [(0, -1), (0, 1), (-1, 0), (1, 0)]:
if self.get(x + dx, y + dy) == WATER:
self.set(x + dx, y + dy, STEAM)
# Rise up
above = self.get(x, y - 1)
if above == EMPTY:
if random.random() < 0.7: # Usually rises
self.set(x, y, EMPTY)
self.set(x, y - 1, FIRE)
return True
# Spread sideways sometimes
if random.random() < 0.3:
direction = random.choice([-1, 1])
if self.get(x + direction, y) == EMPTY:
self.set(x, y, EMPTY)
self.set(x + direction, y, FIRE)
return True
return False
def update_steam(self, x, y):
"""Steam rises and dissipates"""
if random.random() < 0.15:
self.set(x, y, EMPTY)
return True
above = self.get(x, y - 1)
if above == EMPTY:
self.set(x, y, EMPTY)
self.set(x, y - 1, STEAM)
return True
# Drift sideways
direction = random.choice([-1, 1])
if self.get(x + direction, y - 1) == EMPTY:
self.set(x, y, EMPTY)
self.set(x + direction, y - 1, STEAM)
return True
return False
def update(self):
"""Update all particles (bottom to top, randomized horizontal)"""
self.frame += 1
# Process bottom to top so falling works correctly
for y in range(self.height - 1, -1, -1):
# Randomize horizontal order to prevent bias
xs = list(range(self.width))
random.shuffle(xs)
for x in xs:
cell = self.grid[y][x]
if cell == SAND:
self.update_sand(x, y)
elif cell == WATER:
self.update_water(x, y)
elif cell == FIRE:
self.update_fire(x, y)
elif cell == STEAM:
self.update_steam(x, y)
def spawn_material(self, material, count=1, x=None):
"""Spawn material at the top"""
for _ in range(count):
if x is None:
spawn_x = random.randint(5, self.width - 6)
else:
spawn_x = x + random.randint(-2, 2)
if self.get(spawn_x, 0) == EMPTY:
self.set(spawn_x, 0, material)
def add_floor(self):
"""Add a stone floor"""
for x in range(self.width):
self.set(x, self.height - 1, STONE)
def add_obstacle(self, x, y, width, height):
"""Add a stone obstacle"""
for dy in range(height):
for dx in range(width):
self.set(x + dx, y + dy, STONE)
def render(self):
"""Render the grid with colors"""
lines = []
lines.append('┌' + '─' * self.width + '┐')
for row in self.grid:
line = '│'
for cell in row:
line += COLORS.get(cell, '') + cell + RESET
line += '│'
lines.append(line)
lines.append('└' + '─' * self.width + '┘')
lines.append(f' Frame: {self.frame} │ Sand: {COLORS[SAND]}{RESET} Water: {COLORS[WATER]}~{RESET} Fire: {COLORS[FIRE]}*{RESET} Steam: {COLORS[STEAM]}.{RESET}')
return '\n'.join(lines)
def count_materials(self):
"""Count each material type"""
counts = {SAND: 0, WATER: 0, STONE: 0, FIRE: 0, STEAM: 0}
for row in self.grid:
for cell in row:
if cell in counts:
counts[cell] += 1
return counts
def main():
world = SandWorld(WIDTH, HEIGHT)
# Add floor and some obstacles
world.add_floor()
world.add_obstacle(15, 15, 8, 2) # Platform left
world.add_obstacle(37, 15, 8, 2) # Platform right
world.add_obstacle(26, 10, 8, 2) # Platform center high
# Spawn pattern
spawn_cycle = 0
print('\033[2J') # Clear screen
for frame in range(200):
# Clear and render
print('\033[H') # Move cursor to top
print(world.render())
# Spawn materials in patterns
spawn_cycle = frame % 60
if spawn_cycle < 20:
# Sand from left
world.spawn_material(SAND, 2, x=10)
elif spawn_cycle < 40:
# Water from right
world.spawn_material(WATER, 2, x=50)
else:
# Fire from center
if frame % 3 == 0:
world.spawn_material(FIRE, 1, x=30)
# Update physics
world.update()
time.sleep(0.05)
# Final stats
counts = world.count_materials()
print(f"\n Final state: Sand={counts[SAND]} Water={counts[WATER]} Steam={counts[STEAM]}")
print("\n Just physics. No metaphor needed.\n")
if __name__ == '__main__':
main()