#!/usr/bin/env python3 import argparse import math import random import shutil import sys import time RAMP = " .,:;-=+*#%@" PALETTE = [ (255, 90, 120), (255, 200, 80), (120, 220, 255), (190, 120, 255), (120, 255, 170), (255, 140, 60), (255, 255, 230), (255, 110, 220), ] GRAVITY = 4.5 ROCKET_G = 10.0 ASPECT = 0.5 def mix(c, k): return tuple(min(255, int(v * k)) for v in c) def lerp(a, b, t): return tuple(int(x + (y - x) * t) for x, y in zip(a, b)) class Particle: __slots__ = ("x", "y", "vx", "vy", "life", "max_life", "color", "drag", "grav", "kind", "twinkle", "shell", "colors") def __init__(self, x, y, vx, vy, life, color, drag=1.6, grav=GRAVITY, kind="spark", twinkle=False): self.x, self.y, self.vx, self.vy = x, y, vx, vy self.life = self.max_life = life self.color, self.drag, self.grav, self.kind, self.twinkle = color, drag, grav, kind, twinkle class Show: def __init__(self, cols, rows): self.resize(cols, rows) self.parts = [] self.t = 0.0 self.next_launch = 0.3 self.flash = 0.0 def resize(self, cols, rows): self.cols, self.rows = cols, rows self.horizon = int(rows * 0.68) self.glow = [[0.0] * cols for _ in range(rows)] self.tint = [[(0, 0, 0)] * cols for _ in range(rows)] self.skyline = self.make_skyline() def make_skyline(self): rng = random.Random(42) h = [0] * self.cols x = 0 while x < self.cols: w = rng.randint(2, 6) height = rng.choice([1, 1, 2, 2, 3, 3, 4, 5, 6]) if rng.random() < 0.08: height = 11 for i in range(x, min(self.cols, x + w)): h[i] = height x += w windows = {(i, j) for i in range(self.cols) for j in range(h[i]) if j > 0 and rng.random() < 0.12} return h, windows def launch(self, x=None): x = x if x is not None else random.uniform(self.cols * 0.12, self.cols * 0.88) top = random.uniform(self.horizon * 0.12, self.horizon * 0.45) vy = -math.sqrt(2 * ROCKET_G * (self.horizon - top)) rocket = Particle(x, self.horizon - 1, random.uniform(-1.5, 1.5), vy, 9.0, (255, 220, 170), drag=0.0, grav=ROCKET_G, kind="rocket") rocket.shell = random.choice(["peony", "peony", "ring", "willow", "crossette", "palm", "double"]) rocket.colors = random.sample(PALETTE, 2) self.parts.append(rocket) def burst(self, p): c1, c2 = p.colors kind = p.shell n = random.randint(70, 110) speed = random.uniform(14, 20) if kind == "ring": tilt = random.uniform(0.3, 1.0) for i in range(n): a = 2 * math.pi * i / n self.parts.append(Particle(p.x, p.y, math.cos(a) * speed, math.sin(a) * speed * tilt * ASPECT, random.uniform(1.6, 2.2), c1)) elif kind == "willow": for _ in range(n): a, s = random.uniform(0, 2 * math.pi), speed * random.uniform(0.3, 0.8) self.parts.append(Particle(p.x, p.y, math.cos(a) * s, math.sin(a) * s * ASPECT, random.uniform(3.0, 4.2), (255, 190, 90), drag=1.2, grav=GRAVITY * 0.6, kind="willow")) elif kind == "palm": for k in range(8): a = 2 * math.pi * k / 8 + random.uniform(-0.1, 0.1) for j in range(10): s = speed * (0.45 + j * 0.06) self.parts.append(Particle(p.x, p.y, math.cos(a) * s, math.sin(a) * s * ASPECT, random.uniform(2.0, 2.6), lerp(c1, (255, 230, 160), j / 10))) else: for _ in range(n): a = random.uniform(0, 2 * math.pi) r = math.sqrt(random.random()) if kind != "peony" else random.uniform(0.85, 1.0) s = speed * r col = c1 if random.random() < 0.6 else c2 q = Particle(p.x, p.y, math.cos(a) * s, math.sin(a) * s * ASPECT, random.uniform(1.5, 2.3), col, twinkle=random.random() < 0.3) if kind == "crossette": q.kind = "crossette" q.life = q.max_life = random.uniform(0.7, 1.0) self.parts.append(q) if kind == "double": for _ in range(40): a, s = random.uniform(0, 2 * math.pi), speed * 0.45 self.parts.append(Particle(p.x, p.y, math.cos(a) * s, math.sin(a) * s * ASPECT, 1.4, c2)) self.flash = 1.0 def split(self, p): for k in range(4): a = math.pi / 4 + k * math.pi / 2 self.parts.append(Particle(p.x, p.y, math.cos(a) * 7, math.sin(a) * 7 * ASPECT, 0.9, mix(p.color, 1.2))) def step(self, dt): self.t += dt self.flash *= 0.82 self.next_launch -= dt if self.next_launch <= 0: for _ in range(random.choice([1, 1, 1, 2, 3])): self.launch() self.next_launch = random.uniform(0.35, 1.3) alive = [] for p in self.parts: p.life -= dt p.vx -= p.vx * p.drag * dt p.vy -= p.vy * p.drag * dt p.vy += p.grav * dt px, py = p.x, p.y p.x += p.vx * dt p.y += p.vy * dt if p.kind == "rocket": if p.vy >= -1.0 or p.life <= 0: self.burst(p) continue self.stamp(px, py, 0.55, (255, 170, 90)) elif p.life <= 0: if p.kind == "crossette": self.split(p) continue frac = max(0.0, p.life / p.max_life) bright = frac ** 0.7 if p.twinkle and random.random() < 0.35: bright *= 0.2 color = p.color if frac > 0.25 else lerp((255, 120, 40), p.color, frac * 4) self.stamp(p.x, p.y, bright, color) self.stamp((p.x + px) / 2, (p.y + py) / 2, bright * 0.5, color) alive.append(p) self.parts = alive decay = math.exp(-dt * 5.5) for row in self.glow: for i in range(self.cols): row[i] *= decay def stamp(self, x, y, b, color): i, j = int(x), int(y) if 0 <= i < self.cols and 0 <= j < self.horizon: g = self.glow[j][i] if b > g * 0.6: self.tint[j][i] = color if b >= g else lerp(self.tint[j][i], color, 0.5) self.glow[j][i] = min(1.4, max(g, b) + min(g, b) * 0.3) def render(self): cols, rows, hz = self.cols, self.rows, self.horizon heights, windows = self.skyline flash = self.flash out = ["\x1b[H"] for r in range(rows): line, last = [], None for c in range(cols): if r < hz: g = self.glow[r][c] base_h = hz - r if base_h <= heights[c]: lit = (c, base_h - 1) in windows ch = "▪" if lit else "█" col = (230, 200, 120) if lit else (14 + int(30 * flash), 16 + int(30 * flash), 30 + int(40 * flash)) elif g > 0.03: ch = RAMP[min(len(RAMP) - 1, int(g / 1.4 * len(RAMP)))] col = mix(self.tint[r][c], 0.35 + 0.75 * min(1.0, g)) else: ch, col = " ", None else: d = r - hz src = hz - 2 - int(d * hz / max(1, rows - hz)) wave = int(round(math.sin(self.t * 2.2 + r * 0.9) * (1 + d * 0.15))) sc = min(cols - 1, max(0, c + wave)) g = self.glow[src][sc] * (0.55 - d * 0.03) if src >= 0 else 0 if d == 0: ch, col = "~", (40, 60, 110) elif g > 0.04: ch = "~-=≈"[min(3, int(g * 4))] col = mix(self.tint[src][sc], 0.25 + g) elif hash((c, r, int(self.t * 2 + c * 0.13))) % 41 == 0: ch, col = "-", (30, 45, 80) else: ch, col = " ", None if col != last and col is not None: line.append(f"\x1b[38;2;{col[0]};{col[1]};{col[2]}m") last = col line.append(ch) out.append("".join(line)) if r < rows - 1: out.append("\n") return "".join(out) def main(): ap = argparse.ArgumentParser(description="ASCII fireworks in your terminal") ap.add_argument("--fps", type=int, default=30) args = ap.parse_args() size = shutil.get_terminal_size((100, 32)) show = Show(size.columns, size.lines - 1) sys.stdout.write("\x1b[?25l\x1b[2J") try: last = time.perf_counter() while True: now = time.perf_counter() dt, last = min(0.1, now - last), now s = shutil.get_terminal_size((100, 32)) if (s.columns, s.lines - 1) != (show.cols, show.rows): show.resize(s.columns, s.lines - 1) sys.stdout.write("\x1b[2J") show.step(dt) sys.stdout.write(show.render() + "\x1b[0m") sys.stdout.flush() time.sleep(max(0, 1 / args.fps - (time.perf_counter() - now))) except KeyboardInterrupt: pass finally: sys.stdout.write("\x1b[0m\x1b[?25h\n") if __name__ == "__main__": main()