pyblock/pybitblock/clock/animations.py

194 lines
5.9 KiB
Python
Raw Normal View History

"""Visual animations for the block clock.
Mining rain, odometer digit transition, fireworks on milestones.
"""
import shutil
import subprocess
import sys
import time
from random import choice, randrange
from cfonts import render
# Halving blocks for milestone detection
HALVING_BLOCKS = {210_000 * i for i in range(1, 65)}
def is_milestone_block(height):
"""Check if block is a milestone. Returns description or None."""
if height in HALVING_BLOCKS:
return f"HALVING #{height // 210_000}"
if height % 100_000 == 0:
return f"BLOCK {height:,}"
if height % 10_000 == 0:
return f"BLOCK {height:,}"
return None
def mining_animation(duration=3.0):
"""Matrix-style mining rain animation for new block discovery.
Uses inline implementation to avoid import issues with terminal_matrix.
"""
cols, lines = shutil.get_terminal_size((80, 24))
chars = [chr(i) for i in range(0x30, 0x80)]
green = "\033[32m"
bright_green = "\033[1;32m"
reset = "\033[0m"
# Initialize cascades
cascades = {}
sys.stdout.write("\033[2J\033[H\x1b[?25l")
end_time = time.time() + duration
while time.time() < end_time:
# Spawn new cascades
if len(cascades) < cols // 2:
col = randrange(1, cols + 1)
if col not in cascades:
speed = randrange(1, 4)
length = randrange(4, lines // 2)
cascades[col] = {'row': 1, 'speed': speed, 'length': length}
buf = []
to_remove = []
for col, c in cascades.items():
row = c['row']
if row <= lines:
char = choice(chars)
buf.append(f"\033[{row};{col}H{bright_green}{char}")
# Dim the trail
trail_row = row - c['length']
if 1 <= trail_row <= lines:
buf.append(f"\033[{trail_row};{col}H{reset} ")
c['row'] += c['speed']
if c['row'] - c['length'] > lines:
to_remove.append(col)
for col in to_remove:
del cascades[col]
if buf:
sys.stdout.write(''.join(buf))
sys.stdout.flush()
time.sleep(0.03)
sys.stdout.write(f"\033[2J\033[H{reset}\x1b[?25l")
sys.stdout.flush()
def odometer_transition(old_height_str, new_height_str, settings, start_row):
"""Animate changing digits like a mechanical odometer.
Renders intermediate digit values at the positions that changed.
"""
colors = [settings.get('colorA', 'green'), settings.get('colorB', 'yellow')]
font = settings.get('design', 'block')
# Pad to same length
max_len = max(len(old_height_str), len(new_height_str))
old = old_height_str.zfill(max_len)
new = new_height_str.zfill(max_len)
# Find which digits changed
changed = [i for i in range(max_len) if old[i] != new[i]]
if not changed:
return
# Animate: show 3 intermediate frames
frames = 3
for frame in range(frames):
intermediate = list(old)
for i in changed:
old_d = int(old[i])
new_d = int(new[i])
# Roll through digits
step = (old_d + (frame + 1) * (new_d - old_d + 10) // (frames + 1)) % 10
if frame == frames - 1:
step = new_d
intermediate[i] = str(step)
text = ''.join(intermediate)
output = render(text, colors=colors, align='center', font=font)
lines = output.rstrip('\n').split('\n')
buf = []
for j, line in enumerate(lines):
buf.append(f"\033[{start_row + j};1H\033[2K{line}")
sys.stdout.write(''.join(buf))
sys.stdout.flush()
time.sleep(0.12)
def fireworks_animation(term_width, term_height, duration=5.0):
"""ASCII fireworks celebration for milestone blocks."""
colors = [
"\033[1;31m", # red
"\033[1;33m", # yellow
"\033[1;32m", # green
"\033[1;36m", # cyan
"\033[1;35m", # magenta
"\033[1;37m", # white
]
sparks = ['*', '.', '+', 'o', '\u2022', '\u2726', '\u2727', '\u2728']
reset = "\033[0m"
sys.stdout.write("\033[2J\033[H\x1b[?25l")
end_time = time.time() + duration
explosions = []
while time.time() < end_time:
# Spawn new explosion
if randrange(5) == 0 or not explosions:
cx = randrange(5, term_width - 5)
cy = randrange(3, term_height - 3)
color = choice(colors)
explosions.append({
'cx': cx, 'cy': cy, 'color': color,
'radius': 0, 'max_radius': randrange(3, 8),
'age': 0
})
buf = []
alive = []
for exp in explosions:
exp['age'] += 1
exp['radius'] = min(exp['radius'] + 1, exp['max_radius'])
if exp['age'] > exp['max_radius'] * 3:
# Fade: clear spark positions
for _ in range(8):
dx = randrange(-exp['max_radius'], exp['max_radius'] + 1)
dy = randrange(-exp['max_radius'] // 2, exp['max_radius'] // 2 + 1)
x = exp['cx'] + dx
y = exp['cy'] + dy
if 1 <= x <= term_width and 1 <= y <= term_height:
buf.append(f"\033[{y};{x}H ")
continue
alive.append(exp)
r = exp['radius']
for _ in range(r * 4):
dx = randrange(-r, r + 1)
dy = randrange(-r // 2, r // 2 + 1)
x = exp['cx'] + dx
y = exp['cy'] + dy
if 1 <= x <= term_width and 1 <= y <= term_height:
spark = choice(sparks)
buf.append(f"\033[{y};{x}H{exp['color']}{spark}")
explosions = alive
if buf:
sys.stdout.write(''.join(buf) + reset)
sys.stdout.flush()
time.sleep(0.08)
sys.stdout.write(f"\033[2J\033[H{reset}\x1b[?25l")
sys.stdout.flush()