- 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>
307 lines
10 KiB
Python
307 lines
10 KiB
Python
"""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),
|
||
}
|