mrpquest/tools/gen_art.py
type-two 3241b6f14d MORPQUEST: Record Store Day — a Beyond Morp graphical adaptation
Fourteen EGA screens for MRPGI: nano banana backgrounds squeezed into the
engine's 160x168 double-wide-pixel frame, hand-pixelled item sprites, the
full RSD flag chain (poster frenzy -> yard door secret -> roof -> side door
-> clerk favours -> back-room seam -> payment -> notice -> BeckyWall
dispersal), the five-word true ending, and two scripted test paths (golden
win + gate locks), both green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:56:30 +10:00

147 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""MORPQUEST art pipeline — nano banana (OpenRouter) -> Sierra EGA assets.
Usage:
python3 tools/gen_art.py bg <slug> one background from PROMPTS
python3 tools/gen_art.py sprite <slug> one magenta-keyed sprite
python3 tools/gen_art.py all everything not already on disk
Raw generations cached in art/raw/ (git-ignored); processed PNGs land in
sprites/ where the engine quantizes them to EGA on load (alpha<128 = clear).
"""
import base64, json, os, re, sys, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(ROOT, 'art', 'raw')
SPRITES = os.path.join(ROOT, 'sprites')
os.makedirs(RAW, exist_ok=True)
os.makedirs(SPRITES, exist_ok=True)
MODEL = os.environ.get('TG_ART_MODEL', 'google/gemini-3.1-flash-image')
KEY = os.environ.get('OPENROUTER_KEY')
if not KEY: # family convention — key lives in dealgod, never in a repo
for line in open(os.path.expanduser('~/Documents/dealgod/lore/keys.env')):
if line.startswith('OPENROUTER_KEY='):
KEY = line.strip().split('=', 1)[1]
W, H = 160, 168 # engine picture resolution; GUI displays it 320x168 (2x-wide pixels)
EGA = ('strict 16-color EGA palette only (black, dark blue, dark green, dark cyan, dark red, '
'dark magenta, brown, light gray, dark gray, bright blue, bright green, bright cyan, '
'bright red, bright magenta, yellow, white)')
BG_STYLE = ('Background art from a 1989 Sierra On-Line EGA adventure game, King\'s Quest / '
f'Police Quest II era. {EGA}. Flat color fills with occasional coarse dither '
'patterns, hard aliased pixel edges, no gradients, no smooth shading, no '
'anti-aliasing, no text, no letters, no UI, no people or animals. Wide landscape '
'scene from a slightly raised third-person view, with an open walkable ground '
'plane across the lower half of the frame. ')
SPRITE_STYLE = (f'A single video game sprite from a 1989 Sierra EGA adventure game. {EGA}. '
'Flat colors, hard pixel edges, no anti-aliasing, no gradients, no text. '
'Full figure, facing the viewer, centered, on a SOLID PURE MAGENTA '
'background (#FF00FF) with nothing else. ')
PROMPTS = {'bg': {}, 'sprite': {}} # filled by tools/art_manifest.py or edits below
try:
from art_manifest import PROMPTS # noqa: F401
except ImportError:
pass
def gen(prompt):
body = json.dumps({'model': MODEL, 'modalities': ['image', 'text'],
'messages': [{'role': 'user', 'content': prompt}]}).encode()
req = urllib.request.Request('https://openrouter.ai/api/v1/chat/completions', data=body,
headers={'Authorization': f'Bearer {KEY}',
'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=180) as r:
d = json.load(r)
msg = d['choices'][0]['message']
imgs = msg.get('images') or []
url = imgs[0]['image_url']['url'] if imgs else ''
m = re.match(r'data:image/(\w+);base64,(.+)', url, re.S)
if not m:
raise RuntimeError(f'no image in response: {str(msg)[:200]}')
return base64.b64decode(m.group(2))
def bg(slug, force=False):
out = os.path.join(SPRITES, f'bg_{slug}.png')
if os.path.exists(out) and not force:
return out
raw = os.path.join(RAW, f'bg_{slug}.png')
if not os.path.exists(raw) or force:
open(raw, 'wb').write(gen(BG_STYLE + PROMPTS['bg'][slug]))
from PIL import Image
img = Image.open(raw).convert('RGB')
img = img.resize((W, H), Image.LANCZOS) # squeeze wide gen -> 2x-wide-pixel frame
img.save(out)
print('bg ', slug, '->', out)
return out
def sprite(slug, force=False):
"""Chroma-keyed sprite. PROMPTS['sprite'][slug] = (prompt, target_height_px)."""
out = os.path.join(SPRITES, f'{slug}.png')
if os.path.exists(out) and not force:
return out
prompt, target_h = PROMPTS['sprite'][slug]
raw = os.path.join(RAW, f'sp_{slug}.png')
if not os.path.exists(raw) or force:
open(raw, 'wb').write(gen(SPRITE_STYLE + prompt))
from PIL import Image
img = Image.open(raw).convert('RGB')
img.thumbnail((256, 256), Image.LANCZOS) # work small: fast + smooths jpeg noise
px = img.load()
w, h = img.size
# key by distance to the sampled corner colors (the model's magenta varies)
corners = {px[0, 0], px[w-1, 0], px[0, h-1], px[w-1, h-1]}
def bg(p):
return any((p[0]-c[0])**2 + (p[1]-c[1])**2 + (p[2]-c[2])**2 < 3600 for c in corners)
solid = [[not bg(px[x, y]) for x in range(w)] for y in range(h)]
# keep only the largest connected blob (kills stray decals/debris)
seen = [[False]*w for _ in range(h)]
best = []
for y0 in range(h):
for x0 in range(w):
if solid[y0][x0] and not seen[y0][x0]:
blob, stack = [], [(x0, y0)]
seen[y0][x0] = True
while stack:
x, y = stack.pop()
blob.append((x, y))
for nx, ny in ((x+1,y),(x-1,y),(x,y+1),(x,y-1)):
if 0 <= nx < w and 0 <= ny < h and solid[ny][nx] and not seen[ny][nx]:
seen[ny][nx] = True
stack.append((nx, ny))
if len(blob) > len(best):
best = blob
alpha = Image.new('L', (w, h), 0)
ap = alpha.load()
for x, y in best:
ap[x, y] = 255
img = img.convert('RGBA')
img.putalpha(alpha)
img = img.crop(img.getbbox())
# engine pixels display 2x wide; squeeze width, but keep Sierra-chunky
ratio = target_h / img.height
img = img.resize((max(3, round(img.width * ratio * 0.75)), target_h), Image.LANCZOS)
img.putalpha(img.getchannel('A').point(lambda a: 255 if a >= 100 else 0))
img.save(out)
print('spr', slug, f'{img.width}x{img.height}', '->', out)
return out
if __name__ == '__main__':
mode = sys.argv[1] if len(sys.argv) > 1 else 'all'
if mode == 'bg':
bg(sys.argv[2], force='-f' in sys.argv)
elif mode == 'sprite':
sprite(sys.argv[2], force='-f' in sys.argv)
else:
for s in PROMPTS['bg']:
bg(s)
for s in PROMPTS['sprite']:
sprite(s)