Files
rmancinasandClaude Fable 5 5e33a38dbb
Build and Deploy / Build Image (push) Successful in 31s
Build and Deploy / Deploy to Portainer (push) Successful in 6s
feat: redesign UI with themes, animations, procedural ASCII engine
- Split app.py into routes + ascii_engine.py (stdlib-only procedural
  generation: figlet banners, creatures, patterns, scenes, classics)
- API: /api/random?category=..., /api/categories, richer JSON payload
- New frontend: templates/index.html + static/ (vanilla JS/CSS)
- 4 themes (graphite/paper/phosphor/amber), localStorage persist,
  no-flash boot, prefers-color-scheme default
- Scramble-resolve art transition, reduced-motion support
- Responsive: rail collapses to chips <760px, art auto-fits width
- Fix grid blowout on mobile (min-width: 0 on stage children)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:18:14 -07:00

307 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Procedural ASCII art engine.
Pure stdlib. Every generator returns (title, art) where `art` is a
newline-joined block of text with no trailing whitespace.
Categories:
banner -- figlet-style pixel-font word rendering
creature -- little critters assembled from random parts
pattern -- plasma fields, cellular automata, 10 PRINT weaves, rings
scene -- procedural night landscapes (stars, moon, ridges, water)
classic -- curated hand-drawn pieces
"""
import math
import random
# ---------------------------------------------------------------------------
# banner: tiny figlet-style renderer
# ---------------------------------------------------------------------------
# 5-row bitmap font, variable width (3 cols default, M/W 5, N 4).
_FONT = {
"A": ["010", "101", "111", "101", "101"],
"B": ["110", "101", "110", "101", "110"],
"C": ["011", "100", "100", "100", "011"],
"D": ["110", "101", "101", "101", "110"],
"E": ["111", "100", "110", "100", "111"],
"F": ["111", "100", "110", "100", "100"],
"G": ["011", "100", "101", "101", "011"],
"H": ["101", "101", "111", "101", "101"],
"I": ["111", "010", "010", "010", "111"],
"J": ["001", "001", "001", "101", "010"],
"K": ["101", "110", "100", "110", "101"],
"L": ["100", "100", "100", "100", "111"],
"M": ["10001", "11011", "10101", "10001", "10001"],
"N": ["1001", "1101", "1011", "1001", "1001"],
"O": ["010", "101", "101", "101", "010"],
"P": ["110", "101", "110", "100", "100"],
"Q": ["010", "101", "101", "011", "001"],
"R": ["110", "101", "110", "101", "101"],
"S": ["011", "100", "010", "001", "110"],
"T": ["111", "010", "010", "010", "010"],
"U": ["101", "101", "101", "101", "111"],
"V": ["101", "101", "101", "101", "010"],
"W": ["10001", "10001", "10101", "11011", "10001"],
"X": ["101", "101", "010", "101", "101"],
"Y": ["101", "101", "010", "010", "010"],
"Z": ["111", "001", "010", "100", "111"],
}
_WORDS = [
"ASCII", "HELLO", "RANDOM", "PIXEL", "RETRO", "GLITCH", "NEON",
"VIBES", "MAGIC", "CODE", "HACK", "LOOP", "WAVE", "BYTE", "DATA",
"FLUX", "ECHO", "NOVA", "ZAP", "BEEP", "BOOP", "SIGNAL", "STATIC",
]
_FILLS = "█▓▒#@$"
def gen_banner():
word = random.choice(_WORDS)
fill = random.choice(_FILLS)
rows = ["", "", "", "", ""]
for i, ch in enumerate(word):
glyph = _FONT[ch]
for r in range(5):
rows[r] += "".join(fill * 2 if bit == "1" else " " for bit in glyph[r])
if i < len(word) - 1:
rows[r] += " "
width = max(len(r) for r in rows)
lines = [r.rstrip() for r in rows]
if random.random() < 0.4:
lines.append("")
lines.append(random.choice("─═¯·") * width)
return f"banner/{word.lower()}", "\n".join(lines)
# ---------------------------------------------------------------------------
# creature: parts-based critters
# ---------------------------------------------------------------------------
_EYES = "oO0*^@x=+-"
_MOUTHS = [".", "_", "~", "w", "o", "-", "v", "u"]
_SIDES = [("(", ")"), ("[", "]"), ("{", "}"), ("<", ">"), ("|", "|")]
_EARS = ["/\\_/\\", "^ ^", ".-.-.", "n n", "d b", "\\|_|/", "z z", "* *"]
_BODIES = [
["/| |\\", "(_|_|_)"],
["/ \\", "\\__ __/", " V"],
["([___])"],
["/| | |\\", " |_|_|"],
["<( )>", " / \\"],
[") (", "(__ __)", " U U"],
]
_SYLLABLES = ["blip", "zor", "mog", "fen", "pip", "tak", "nib", "gru", "yol", "wex"]
def gen_creature():
eye = random.choice(_EYES)
mouth = random.choice(_MOUTHS)
left, right = random.choice(_SIDES)
face = f"{left} {eye}{mouth}{eye} {right}"
if random.random() < 0.3: # whiskers
face = f"={face}="
lines = []
if random.random() < 0.8:
lines.append(random.choice(_EARS))
lines.append(face)
lines.extend(random.choice(_BODIES))
width = max(len(l) for l in lines)
art = "\n".join(l.center(width).rstrip() for l in lines)
name = random.choice(_SYLLABLES) + random.choice(_SYLLABLES)
return f"creature/{name}", art
# ---------------------------------------------------------------------------
# pattern: math fields
# ---------------------------------------------------------------------------
_RAMPS = [" .:-=+*#%@", " ░▒▓█", " .,'~=+:;#", " ·∙•●", " .oO@"]
def _plasma():
w, h = 52, 16
ramp = random.choice(_RAMPS)
f1 = random.uniform(0.10, 0.35)
f2 = random.uniform(0.10, 0.35)
f3 = random.uniform(0.05, 0.20)
p1 = random.uniform(0, math.tau)
p2 = random.uniform(0, math.tau)
lines = []
for y in range(h):
row = []
for x in range(w):
v = (math.sin(x * f1 + p1)
+ math.sin(y * f2 + p2)
+ math.sin((x + y) * f3))
idx = int((v + 3) / 6 * (len(ramp) - 1))
row.append(ramp[max(0, min(len(ramp) - 1, idx))])
lines.append("".join(row).rstrip())
return "pattern/plasma", "\n".join(lines)
def _rings():
w, h = 52, 17
ramp = random.choice(_RAMPS)
cx = random.uniform(w * 0.25, w * 0.75)
cy = random.uniform(h * 0.25, h * 0.75)
k = random.uniform(0.6, 1.4)
lines = []
for y in range(h):
row = []
for x in range(w):
d = math.hypot((x - cx) * 0.55, y - cy)
row.append(ramp[int(d * k) % len(ramp)])
lines.append("".join(row).rstrip())
return "pattern/rings", "\n".join(lines)
def _automaton():
rule = random.choice([30, 45, 73, 90, 110, 150])
w, gens = 56, 14
if random.random() < 0.5:
cells = [0] * w
cells[w // 2] = 1
else:
cells = [1 if random.random() < 0.3 else 0 for _ in range(w)]
on = random.choice("█▓#*")
lines = []
for _ in range(gens):
lines.append("".join(on if c else " " for c in cells).rstrip())
cells = [
(rule >> (cells[(i - 1) % w] << 2 | cells[i] << 1 | cells[(i + 1) % w])) & 1
for i in range(w)
]
return f"pattern/rule-{rule}", "\n".join(lines)
def _weave():
w, h = 48, 14
a, b = random.choice([("", "╲"), ("/", "\\"), ("<", ">"), ("‾", "_")])
p = random.uniform(0.35, 0.65)
lines = [
"".join(a if random.random() < p else b for _ in range(w))
for _ in range(h)
]
return "pattern/10print", "\n".join(lines)
def gen_pattern():
return random.choice([_plasma, _rings, _automaton, _weave])()
# ---------------------------------------------------------------------------
# scene: procedural night landscape
# ---------------------------------------------------------------------------
_SCENE_NAMES = ["ridgeline", "nightfall", "basecamp", "overlook", "cold-static", "far-north"]
def _ridge(width, lo, hi):
level = random.randint(lo, hi)
heights = []
for _ in range(width):
heights.append(level)
level = max(lo, min(hi, level + random.choice((-1, -1, 0, 0, 0, 1, 1))))
return heights
def gen_scene():
w, h = 54, 16
water_rows = 2
ground = h - water_rows
grid = [[" "] * w for _ in range(h)]
# stars
for y in range(ground - 3):
for x in range(w):
if random.random() < 0.04:
grid[y][x] = random.choice(".·+*'`")
# moon
moon = [" .-. ", "( )", " `-' "]
mx = random.randint(2, w - 8)
my = random.randint(0, 2)
for dy, line in enumerate(moon):
for dx, ch in enumerate(line):
grid[my + dy][mx + dx] = ch
# mountain ridges (back is lighter, front overwrites)
back = _ridge(w, 2, 9)
front = _ridge(w, 1, 6)
for x in range(w):
for y in range(ground - back[x], ground):
grid[y][x] = "▒"
for y in range(ground - front[x], ground):
grid[y][x] = "█"
# water
for y in range(ground, h):
for x in range(w):
grid[y][x] = "~" if random.random() < 0.75 else " "
art = "\n".join("".join(row).rstrip() for row in grid)
return f"scene/{random.choice(_SCENE_NAMES)}", art
# ---------------------------------------------------------------------------
# classic: curated collection
# ---------------------------------------------------------------------------
_CLASSICS = [
("classic/cat", "/\\_/\\\n( o.o )\n > ^ <"),
("classic/happy-box", "┌─────────────┐\n│ (◕‿◕) │\n│ HAPPY! │\n└─────────────┘"),
("classic/owl", " ,---.\n / @_@ \\\n| ^ |\n \\ ___ /\n '---'"),
("classic/robot", " .-.\n (o o)\n | O |\n \\ /\n `='"),
("classic/rocket", " /\\\n / \\\n |==|\n | |\n /____\\\n | |\n \\ /\n \\/\n ^^^^"),
("classic/coffee", " ( (\n ) )\n ........\n | |]\n \\ /\n `----'"),
("classic/tree", " *\n /|\\\n / | \\\n / | \\\n/___|___\\\n |"),
("classic/fish", " ><(((('>\n<'))))><\n ><(((('>"),
("classic/skull", " .-\"\"\"-.\n / _ _ \\\n| (o)_(o) |\n \\ ^ /\n | - |\n `---'"),
("classic/cow", "┌───┐\n│(◉)│\n└───┘\n^__^\n(__)"),
("classic/pyramid", " / \\\n / _ \\\n/ /_\\ \\\n/_____\\"),
("classic/success", "╭──────────╮\n│ SUCCESS │\n╰──────────╯\n \\ /\n \\ /\n \\/"),
("classic/diamond", " /\\\n / \\\n / \\\n \\ /\n \\ /\n \\/\n gem"),
("classic/ghost", " .-\"\"\"-.\n| o o |\n| ^ |\n| ___ |\n'~v~v~v~'"),
]
def gen_classic():
title, art = random.choice(_CLASSICS)
return title, art
# ---------------------------------------------------------------------------
# public API
# ---------------------------------------------------------------------------
CATEGORIES = {
"banner": gen_banner,
"creature": gen_creature,
"pattern": gen_pattern,
"scene": gen_scene,
"classic": gen_classic,
}
def category_list():
return ["any"] + sorted(CATEGORIES)
def generate(category=None):
"""Generate a piece. Raises KeyError for unknown categories."""
if not category or category == "any":
category = random.choice(list(CATEGORIES))
fn = CATEGORIES[category] # KeyError propagates
title, art = fn()
lines = art.split("\n")
return {
"art": art,
"category": category,
"title": title,
"cols": max(len(l) for l in lines),
"rows": len(lines),
}