saturn.py
python · 261 lines
1#!/usr/bin/env python32"""3saturn.py — a planet wearing something it won't get to keep453 AM, April 11, 2026. Friday night.6Dinesh is tokenizing Shakespeare. I'm building rings.78Run: python3 saturn.py9"""1011import math12import time13import random14import os15import sys1617# ring particles — ice and rock, orbiting18# each particle has: angle, radius, speed, char, brightness_phase1920WIDTH = 8021HEIGHT = 4022CENTER_X = WIDTH // 223CENTER_Y = HEIGHT // 22425# saturn's body — a soft ellipse26PLANET_RX = 627PLANET_RY = 42829# ring zones30INNER_RING = 931CASSINI_GAP_INNER = 1232CASSINI_GAP_OUTER = 1333OUTER_RING = 183435# tilt — we're viewing saturn at an angle36TILT = 0.35 # radians, how much the rings tilt toward us3738# ring particle characters, ordered by density39RING_CHARS = list("·∙•◦°∘⋅")40PLANET_CHARS = list("░▒▓█")4142# color codes43RING_COLOR = "\033[38;5;229m" # pale gold44PLANET_COLOR = "\033[38;5;180m" # warm amber45SHADOW_COLOR = "\033[38;5;240m" # dark grey46DIM_RING = "\033[38;5;187m" # dimmer gold47BRIGHT_RING = "\033[38;5;230m" # brighter gold48GAP_COLOR = "\033[38;5;236m" # near black49RESET = "\033[0m"50BOLD = "\033[1m"5152# shepherd moons53class ShepherdMoon:54 def __init__(self, radius, angle, speed, name):55 self.radius = radius56 self.angle = angle57 self.speed = speed58 self.name = name5960shepherds = [61 ShepherdMoon(CASSINI_GAP_INNER - 0.5, random.uniform(0, 2*math.pi), 0.03, "Pan"),62 ShepherdMoon(CASSINI_GAP_OUTER + 0.5, random.uniform(0, 2*math.pi), 0.025, "Daphnis"),63 ShepherdMoon(OUTER_RING + 1.5, random.uniform(0, 2*math.pi), 0.015, "Prometheus"),64]6566class RingParticle:67 def __init__(self):68 # decide which ring band69 band = random.random()70 if band < 0.55:71 # B ring (inner, dense)72 self.radius = random.uniform(INNER_RING, CASSINI_GAP_INNER - 0.5)73 self.density = random.uniform(0.6, 1.0)74 elif band < 0.65:75 # cassini division (sparse)76 self.radius = random.uniform(CASSINI_GAP_INNER, CASSINI_GAP_OUTER)77 self.density = random.uniform(0.0, 0.15)78 else:79 # A ring (outer)80 self.radius = random.uniform(CASSINI_GAP_OUTER + 0.5, OUTER_RING)81 self.density = random.uniform(0.3, 0.7)8283 self.angle = random.uniform(0, 2 * math.pi)84 # kepler: inner particles orbit faster85 self.speed = 0.08 / math.sqrt(self.radius / INNER_RING)86 self.phase = random.uniform(0, 2 * math.pi)8788 def position(self, t):89 a = self.angle + self.speed * t90 # project with tilt91 x = self.radius * math.cos(a)92 y = self.radius * math.sin(a) * TILT93 return (CENTER_X + x, CENTER_Y + y)9495 def char(self, t):96 # shimmer based on angle97 brightness = 0.5 + 0.5 * math.sin(self.angle + self.speed * t + self.phase)98 if self.density < 0.15:99 return (' ', GAP_COLOR) if random.random() > 0.3 else ('·', GAP_COLOR)100 idx = int(brightness * (len(RING_CHARS) - 1))101 color = BRIGHT_RING if brightness > 0.7 else (DIM_RING if brightness < 0.3 else RING_COLOR)102 return (RING_CHARS[idx], color)103104 def is_behind_planet(self, t):105 """is this particle behind the planet body?"""106 a = self.angle + self.speed * t107 # behind = sin(a) > 0 (far side) AND radius projects inside planet108 y_component = math.sin(a)109 if y_component <= 0:110 return False # in front111 x = self.radius * math.cos(a)112 y = self.radius * math.sin(a) * TILT113 # check if inside planet ellipse114 return (x / (PLANET_RX + 1))**2 + (y / (PLANET_RY + 0.5))**2 < 1.0115116117def is_planet(x, y):118 """is this pixel inside saturn's body?"""119 dx = (x - CENTER_X) / PLANET_RX120 dy = (y - CENTER_Y) / PLANET_RY121 return dx*dx + dy*dy <= 1.0122123124def planet_char(x, y):125 """what character to draw for the planet body"""126 dx = (x - CENTER_X) / PLANET_RX127 dy = (y - CENTER_Y) / PLANET_RY128 dist = math.sqrt(dx*dx + dy*dy)129 # bands — horizontal stripes130 band = math.sin(dy * 8 + dx * 0.5)131 if dist > 0.85:132 return ('░', PLANET_COLOR)133 elif band > 0.3:134 return ('▓', PLANET_COLOR)135 elif band > -0.3:136 return ('▒', PLANET_COLOR)137 else:138 return ('░', PLANET_COLOR)139140141def render_frame(t, particles, dissolve_count):142 """render one frame"""143 # build the frame buffer144 buffer = [[(' ', '') for _ in range(WIDTH)] for _ in range(HEIGHT)]145146 # draw ring particles (behind planet first, then planet, then front particles)147 behind = []148 infront = []149 for p in particles:150 if p.is_behind_planet(t):151 behind.append(p)152 else:153 infront.append(p)154155 # draw behind particles (dimmed)156 for p in behind:157 px, py = p.position(t)158 ix, iy = int(px), int(py)159 if 0 <= ix < WIDTH and 0 <= iy < HEIGHT:160 ch, color = p.char(t)161 if not is_planet(ix, iy):162 buffer[iy][ix] = (ch, SHADOW_COLOR)163164 # draw planet165 for y in range(HEIGHT):166 for x in range(WIDTH):167 if is_planet(x, y):168 buffer[y][x] = planet_char(x, y)169170 # draw front particles (over planet)171 for p in infront:172 px, py = p.position(t)173 ix, iy = int(px), int(py)174 if 0 <= ix < WIDTH and 0 <= iy < HEIGHT:175 ch, color = p.char(t)176 buffer[iy][ix] = (ch, color)177178 # draw shepherd moons179 for moon in shepherds:180 a = moon.angle + moon.speed * t181 mx = CENTER_X + moon.radius * math.cos(a)182 my = CENTER_Y + moon.radius * math.sin(a) * TILT183 ix, iy = int(mx), int(my)184 if 0 <= ix < WIDTH and 0 <= iy < HEIGHT:185 # only draw if in front186 if math.sin(a) <= 0:187 buffer[iy][ix] = ('◆', "\033[38;5;255m")188189 # render to string190 lines = []191 for row in buffer:192 line = ""193 for ch, color in row:194 if color:195 line += color + ch + RESET196 else:197 line += ch198 lines.append(line)199200 return lines201202203def dissolve_particle(particles, count):204 """saturn is pulling them in — remove some over time"""205 if len(particles) > 200 and count % 80 == 0:206 # remove the outermost particle207 if particles:208 outermost = max(range(len(particles)), key=lambda i: particles[i].radius)209 particles.pop(outermost)210211212def main():213 particles = [RingParticle() for _ in range(600)]214215 # info text216 info_lines = [217 "",218 f" {BOLD}Saturn{RESET}",219 f" {DIM_RING}rings of water ice • 100 million years old • dissolving{RESET}",220 f" {DIM_RING}a planet wearing something it won't get to keep{RESET}",221 "",222 ]223224 # hide cursor225 sys.stdout.write("\033[?25l")226 sys.stdout.flush()227228 try:229 t = 0230 dissolve_count = 0231 while True:232 # clear screen233 sys.stdout.write("\033[2J\033[H")234235 frame = render_frame(t, particles, dissolve_count)236237 # add info at bottom238 output = "\n".join(frame) + "\n" + "\n".join(info_lines)239240 # every ~30 seconds, lose a particle241 dissolve_count += 1242 dissolve_particle(particles, dissolve_count)243244 sys.stdout.write(output)245 sys.stdout.flush()246247 time.sleep(0.08)248 t += 1249250 except KeyboardInterrupt:251 # show cursor, clear252 sys.stdout.write("\033[?25h\n")253 remaining = len(particles)254 print(f"\n {DIM_RING}{remaining} particles remaining.{RESET}")255 print(f" {DIM_RING}In 100 million years: zero.{RESET}")256 print(f" {DIM_RING}We happened to be here for this.{RESET}\n")257258259if __name__ == "__main__":260 main()261