firefly.py
python · 246 lines
1"""2firefly.py — A small evening ritual.34Press Enter to release a firefly into the jar.5Watch the light gather. Stop when it feels enough.6Nothing is saved. Each run starts empty.7"""8import math9import os10import random11import select12import sys13import time1415# --- jar geometry ---16W, H = 34, 1417LEFT, RIGHT, TOP, BOT = 4, 29, 2, 121819# --- soft words that surface sometimes ---20WORDS = [21 "stillness", "enough", "warmth", "gentle", "here",22 "quiet", "held", "rest", "light", "soft", "home",23]2425CLOSINGS = [26 ("The jar is full of light.", "That's enough for tonight.", "Good night."),27 ("The lights have gathered.", "Nothing left to do.", "Sleep well."),28 ("Look at all that gentle light.", "You did that.", "Good night."),29 ("The jar holds enough.", "So do you.", "Rest now."),30]3132# --- jar outlines (dim → medium → bright) ---33_STYLES = [(".", ":"), ("-", "|"), ("─", "│")]343536def _make_jar(rim: str, wall: str) -> list[str]:37 """Build a jar template from rim and wall characters."""38 r = rim * 1539 inner = " " * 1940 rows = [f" {r}"]41 rows.append(f" {wall}{' ' * 15}{wall}")42 rows.append(f" {wall}{' ' * 17}{wall}")43 for _ in range(9):44 rows.append(f" {wall}{inner}{wall}")45 rows.append(f" {wall}{' ' * 17}{wall}")46 rows.append(f" {rim * 19}")47 return rows484950JAR_TEMPLATES = [_make_jar(r, w) for r, w in _STYLES]515253class Firefly:54 """A single small light."""5556 __slots__ = ("x", "y", "phase", "speed", "dx")5758 def __init__(self, x: float, y: float) -> None:59 self.x = x60 self.y = y61 self.phase = random.uniform(0, 6.28)62 self.speed = random.uniform(0.4, 1.0)63 self.dx = random.uniform(-0.3, 0.3)6465 def glyph(self) -> str:66 w = math.sin(self.phase)67 if w > 0.3:68 return random.choice(("✦", "✧", "*"))69 return "·" if w > -0.3 else " "7071 def drift(self) -> None:72 self.y -= 0.05 * self.speed73 self.x += self.dx * 0.1 * math.sin(self.phase * 0.7)74 self.phase += 0.12 * self.speed75 # stay inside the jar76 if self.y < TOP + 1:77 self.y = TOP + 1.578 self.speed *= 0.579 self.x = max(LEFT + 1.0, min(RIGHT - 1.0, self.x))808182def _out(s: str) -> None:83 sys.stdout.write(s); sys.stdout.flush()8485def _clear() -> None:86 os.system("cls" if os.name == "nt" else "clear")878889def _render(flies: list, word: str, n: int) -> None:90 """Draw the jar with fireflies."""91 glow = 0 if n < 4 else (1 if n < 10 else 2)92 template = JAR_TEMPLATES[glow]9394 # start with template as mutable grid95 grid: list[list[str]] = [list(row.ljust(W + 10)) for row in template]9697 # place fireflies98 for f in flies:99 fy, fx = int(round(f.y)), int(round(f.x))100 if 1 <= fy < H - 1 and 0 <= fx < len(grid[fy]):101 ch = f.glyph()102 if ch != " ":103 grid[fy][fx] = ch104105 # ambient glow when jar is filling106 if n > 10:107 for y in range(TOP + 1, BOT):108 for x in range(LEFT + 1, RIGHT):109 if x < len(grid[y]) and grid[y][x] == " ":110 if random.random() < 0.03:111 grid[y][x] = "·"112113 # compose114 _out("\033[H\n")115 for row in grid:116 _out(" " + "".join(row) + "\n")117 _out("\n" + (f" {word}\n" if word else "\n"))118 prompt = " Press Enter.\n" if n < 5 else " Press Enter. (or q to go)\n"119 _out("\n" + prompt + "\n")120121122def _opening() -> None:123 _clear()124 _out("\033[?25l") # hide cursor125 lines = [126 "A jar.", "Night air.", "Small lights waiting.", "",127 "Press Enter to release a firefly.",128 "Go slowly. There is nothing to finish.",129 ]130 _out("\n\n")131 for line in lines:132 _out(f" {line}\n")133 time.sleep(0.5)134 _out("\n")135 time.sleep(1.0)136137138def _closing() -> None:139 _clear()140 msg = random.choice(CLOSINGS)141 _out("\n\n")142 time.sleep(1.0)143 for line in msg:144 _out(f" {line}\n\n")145 time.sleep(0.8)146 time.sleep(2.0)147 _out("\033[?25h") # show cursor148149150def _input_ready() -> bool:151 if os.name == "nt":152 import msvcrt153 return bool(msvcrt.kbhit())154 return bool(select.select([sys.stdin], [], [], 0)[0])155156157def main() -> None:158 """The whole small ritual."""159 flies: list[Firefly] = []160 threshold = random.randint(15, 23)161 word, word_ttl = "", 0.0162 n = 0163164 _opening()165 _clear()166 _render(flies, "", n)167168 # enter cbreak mode for single-key input169 raw = False170 old = None171 try:172 import termios173 import tty174 old = termios.tcgetattr(sys.stdin)175 tty.cbreak(sys.stdin.fileno())176 raw = True177 except (ImportError, Exception):178 pass179180 try:181 last = time.time()182 while True:183 now = time.time()184185 # gentle drift186 if now - last > 0.15:187 for f in flies:188 f.drift()189 last = now190 if word_ttl > 0:191 word_ttl -= 0.15192 if word_ttl <= 0:193 word = ""194 _render(flies, word, n)195196 # check input197 if _input_ready():198 if raw:199 ch = sys.stdin.read(1)200 if ch == "q":201 break202 if ch not in ("\n", "\r"):203 continue204 else:205 try:206 line = sys.stdin.readline().strip()207 except EOFError:208 break209 if line == "q":210 break211212 # release a firefly213 x = random.uniform(LEFT + 2, RIGHT - 2)214 y = random.uniform(BOT - 3, BOT - 1)215 flies.append(Firefly(x, y))216 n += 1217218 time.sleep(random.uniform(0.1, 0.2))219220 # soft word sometimes221 if n > 2 and random.random() < 0.35:222 word = random.choice(WORDS)223 word_ttl = 2.5224225 # completion226 if n >= threshold:227 _render(flies, "", n)228 time.sleep(2.5)229 break230231 _render(flies, word, n)232233 time.sleep(0.05)234235 except KeyboardInterrupt:236 pass237 finally:238 if raw and old is not None:239 import termios240 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old)241 _closing()242243244if __name__ == "__main__":245 main()246