#!/usr/bin/env python3 """Generate small authored textures for Step Line (assets/ import convention). All outputs are deterministic (fixed seeds), <=1024px, power-of-two where possible. Run from the repo root: python3 tools/generate_textures.py Optional AI mode generates textures through the OpenRouter image API (https://openrouter.ai/docs/api/api-reference/images/generate-an-image.md). Requires an API key in the OPENROUTER_API_KEY environment variable: python3 tools/generate_textures.py --ai python3 tools/generate_textures.py --ai --prompt "..." --name foo.png """ import argparse import base64 import io import json import math import os import random import stat import sys import urllib.error import urllib.request from PIL import Image, ImageDraw, ImageFilter, ImageFont ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") ASSETS = os.path.join(ROOT, "assets", "textures") # OpenRouter image generation endpoint and default model (from the API docs). OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images" DEFAULT_IMAGE_MODEL = "bytedance-seed/seedream-4.5" MAX_DIM = 1024 # hard cap on the longest side of any generated texture # px requested from the API; some models (e.g. seedream) need >= 1920 GEN_SIZE = 2048 # Default batch for `--ai`: one entry per game texture (see test_world.gd). # Each entry is (output name, prompt, transparent_background). AI_TEXTURES = [ # --- world surfaces (seamless, flat-lit) --- ("ai/brick_facade.png", "seamless tileable red brick wall texture, weathered urban facade, " "flat even lighting, frontal view, photorealistic game texture", False), ("ai/concrete.png", "seamless tileable grey concrete wall texture, subtle cracks and stains, " "flat even lighting, frontal view, photorealistic game texture", False), ("ai/asphalt.png", "seamless tileable dark asphalt road texture, fine grain, " "flat even lighting, frontal view, photorealistic game texture", False), # --- storefront dressing --- ("ai/awning.png", "red and white striped storefront awning with scalloped bottom edge, " "frontal view, flat even lighting, game texture", False), ("ai/skyline.png", "night city skyline silhouette, dark building shapes with a few lit " "windows, flat graphic style, isolated on transparent background", True), # --- screen-space overlay --- ("ai/vignette.png", "radial vignette overlay, black edges fading to fully transparent center, " "grayscale, soft gradient", True), # --- HUD icons (white glyphs on transparent) --- ("ai/icons/flashlight_on.png", "simple flat flashlight icon, white glyph on transparent background, " "minimal UI icon", True), ("ai/icons/door_locked.png", "simple flat locked door icon with keyhole, white outline glyph on " "transparent background, minimal UI icon", True), ("ai/icons/wand.png", "simple flat handheld security wand icon, white glyph on transparent " "background, minimal UI icon", True), ("ai/icons/rec_dot.png", "record button icon, red filled dot inside a white circle outline, on " "transparent background, minimal UI icon", True), # --- access artifact cards --- ("ai/cards/id_card.png", "driver license ID card, blue plastic card with photo placeholder and " "text lines, flat frontal view, game UI asset", False), ("ai/cards/wristband.png", "red event access wristband with strap, flat frontal view, game UI asset", False), ("ai/cards/ticket.png", "green admission ticket stub with perforated edge, flat frontal view, " "game UI asset", False), ("ai/cards/credential.png", "dark grey staff credential pass with lanyard clip, flat frontal view, " "game UI asset", False), ("ai/cards/guest_list.png", "beige guest list document with ruled lines and checkboxes, flat frontal " "view, game UI asset", False), ] FONT_CANDIDATES = [ "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf", "/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf", ] def font(size: int): for path in FONT_CANDIDATES: if os.path.exists(path): return ImageFont.truetype(path, size) return ImageFont.load_default() def save(img: Image.Image, name: str) -> None: os.makedirs(ASSETS, exist_ok=True) path = os.path.join(ASSETS, name) os.makedirs(os.path.dirname(path), exist_ok=True) img.save(path, optimize=True) print(f"wrote {os.path.relpath(path, ROOT)} {img.size}") def grain(size, base, spread, seed, blur=1.0): rnd = random.Random(seed) img = Image.new("L", (size, size), base) px = img.load() for y in range(size): for x in range(size): v = base + rnd.randint(-spread, spread) px[x, y] = max(0, min(255, v)) return img.filter(ImageFilter.GaussianBlur(blur)) def brick_facade(): size = 512 mortar = (118, 110, 104) base = (150, 92, 78) img = Image.new("RGB", (size, size), mortar) d = ImageDraw.Draw(img) rnd = random.Random(11) brick_h = size // 22 mortar_t = 5 offset = 0 for row in range(22): y = row * brick_h cols = 2 if row % 2 == 0 else 3 brick_w = (size - mortar_t) // (cols if cols > 1 else 1) for c in range(cols + 1): x0 = c * (brick_w + mortar_t) - (brick_w // 2 if row % 2 else 0) x1 = x0 + brick_w # wrap horizontally so the tile repeats seamlessly d.rectangle([x0, y, x1, y + brick_h - mortar_t], fill=base) # per-brick shading pass for _ in range(size * size // 900): bx = rnd.randint(0, size - 1) by = rnd.randint(0, size - 1) d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)), max(0, base[1] - 10 + rnd.randint(0, 22)), max(0, base[2] - 8 + rnd.randint(0, 18)))) # horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size) for y in [-mortar_t, size - mortar_t]: d.rectangle([0, y, size, y + mortar_t], fill=mortar) save(img, "brick_facade.png") def seamless_concrete(): size = 512 img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA") d = ImageDraw.Draw(img) rnd = random.Random(19) for _ in range(90): x, y = rnd.randint(0, size), rnd.randint(0, size) r = rnd.choice([2, 3, 4]) d.ellipse([x - r, y - r, x + r, y + r], fill=(0, 0, 0, rnd.randint(8, 22))) for _ in range(8): # subtle crack segments x, y = rnd.randint(0, size), rnd.randint(0, size) pts = [(x, y)] for _ in range(6): x += rnd.randint(-24, 24) y += rnd.randint(-8, 8) pts.append((x % size, y % size)) d.line(pts, fill=(70, 70, 70, 170), width=1) save(img, "concrete.png") def asphalt(): size = 512 img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA") d = ImageDraw.Draw(img) rnd = random.Random(31) for _ in range(600): x, y = rnd.randint(0, size), rnd.randint(0, size) d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),) * 3 + (rnd.randint(40, 110),)) save(img, "asphalt.png") def awning(): w, h = 1024, 256 img = Image.new("RGB", (w, h), (140, 40, 45)) d = ImageDraw.Draw(img) stripe = 64 for i in range(w // stripe): if i % 2 == 1: d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], fill=(230, 225, 220)) # scalloped bottom edge for x in range(0, w, 32): d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6) f = font(96) tw = d.textlength("STEP LINE", font=f) d.text(((w - tw) / 2, 30), "STEP LINE", font=f, fill=(255, 250, 235)) # subtle top shading shade = Image.new("RGBA", (w, h), (0, 0, 0, 0)) sd = ImageDraw.Draw(shade) sd.rectangle([0, 0, w, 10], fill=(0, 0, 0, 90)) img = Image.alpha_composite(img.convert("RGBA"), shade) save(img, "awning.png") def skyline(): w, h = 1024, 256 img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) d = ImageDraw.Draw(img) rnd = random.Random(41) build_color = (16, 18, 30, 255) x = -40 while x < w + 40: bw = rnd.randint(64, 170) bh = rnd.randint(150, 400) d.rectangle([x, h - bh, x + bw, h], fill=build_color) # few lit windows for _ in range(bw * bh // 900): wx = x + rnd.randint(4, max(4, bw - 8)) wy = h - rnd.randint(4, bh - 4) if rnd.random() < 0.5: d.rectangle([wx, wy, wx + 6, wy + 8], fill=(255, 200, 120, 220)) x += bw + rnd.randint(10, 46) # Opaque bottom border removed for transparency - planes will now render as silhouettes # d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) # commented out - was causing opaque black slabs save(img, "skyline.png") def vignette(): size = 512 img = Image.new("L", (size, size), 0) px = img.load() c = size / 2.0 max_d = math.hypot(c, c) for y in range(size): for x in range(size): dist = math.hypot(x - c, y - c) / max_d t = max(0.0, (dist - 0.55) / 0.45) px[x, y] = int(255 * (t * t)) save(img, "vignette.png") def icon(name, draw_fn, size=64): img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) d = ImageDraw.Draw(img) draw_fn(d, size) save(img, name) def icons(): def flashlight_on(d, s): d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62], radius=4, fill=(255, 255, 255, 255)) d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s * 0.74), (s*0.62, s*0.62)], fill=(255, 214, 120, 255)) def door_locked(d, s): d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82], outline=(255, 255, 255, 255), width=5) d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], radius=3, fill=(255, 255, 255, 255)) d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, 360, fill=(255, 255, 255, 255), width=5) def wand(d, s): d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90], radius=3, fill=(255, 255, 255, 255)) d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120, 220, 160, 255)) def rec(d, s): d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86], outline=(255, 255, 255, 255), width=5) d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255)) icon("icons/flashlight_on.png", flashlight_on) icon("icons/door_locked.png", door_locked) icon("icons/wand.png", wand) icon("icons/rec_dot.png", rec) def app_icon(): """Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage (PNG) and the Windows installer (ICO).""" def badge(d, s): d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255)) d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)], outline=(120, 130, 170, 255), width=max(2, s//64)) # gold star cx, cy, r = s*0.5, s*0.44, s*0.30 pts = [] for i in range(10): ang = -90 + i * 36 rad = r if i % 2 == 0 else r * 0.42 import math pts.append((cx + rad * math.cos(math.radians(ang)), cy + rad * math.sin(math.radians(ang)))) d.polygon(pts, fill=(255, 205, 80, 255)) icon("icon.png", badge, size=1024) icon("icon.ico", badge, size=256) # ICO for Windows. Must be BMP-entry based: PIL's ICO serializer emits # PNG-compressed entries which NSIS' icon loader rejects. ImageMagick # writes classic BMP entries; fall back to PIL only if ImageMagick is gone # (flag it so a human notices). import subprocess import sys ico_path = os.path.join(ASSETS, "icon.ico") src_png = os.path.join(ASSETS, "icon.png") ran = False for cmd in ( ["magick", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path], ["convert", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path], ): try: subprocess.run(cmd, check=True, capture_output=True) ran = True break except (FileNotFoundError, subprocess.CalledProcessError): continue if ran: print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)") else: print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)") Image.open(src_png).convert("RGBA").save( ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)]) def cards(): CW, CH = 256, 128 def base(card_name, bg, label, band=(0, 0, 0, 0)): img = Image.new("RGBA", (CW, CH), bg + (255,)) d = ImageDraw.Draw(img) d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, outline=(0, 0, 0, 90), width=2) if band and band[3] == 255: d.rectangle([0, CH-34, CW, CH], fill=band) f = font(26) d.text((14, 10), label, font=f, fill=(255, 255, 255, 235)) # body text lines f2 = font(15) y = 52 for _ in range(4): w = 150 - (24 if _ == 3 else 0) d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110)) y += 24 # photo square for id-like cards if "photo" in card_name: d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255)) save(img, f"cards/{card_name}.png") base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), ) base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255)) base("ticket", (56, 120, 96), "ADMIT ONE", band=(20, 80, 55, 255)) base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255)) base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255)) def _api_key() -> str: key = os.environ.get("OPENROUTER_API_KEY", "").strip() if not key: sys.exit("error: OPENROUTER_API_KEY is not set; export it before using --ai") return key def generate_image(prompt: str, model: str, size: int, transparent: bool = False, aspect_ratio: str = "1:1") -> Image.Image: """Call the OpenRouter image API and return the decoded PIL image. Requests a square image at `size` px or a role-shaped image at the matching resolution tier and decodes `data[].b64_json`. Seedream's square requests retain the existing GEN_SIZE floor. When `transparent` is true, the API is asked for a transparent PNG background. """ size = max(1, int(size)) if model == DEFAULT_IMAGE_MODEL: size = max(GEN_SIZE, size) body = { "model": model, "prompt": prompt, "output_format": "png", "n": 1, } if aspect_ratio == "1:1": body["size"] = f"{size}x{size}" else: body["aspect_ratio"] = aspect_ratio body["resolution"] = next( (tier for limit, tier in ((512, "512"), (768, "768"), (1024, "1K"), (1536, "1.5K"), (2048, "2K"), (4096, "4K")) if size <= limit), "4K") if transparent: body["background"] = "transparent" req = urllib.request.Request( OPENROUTER_IMAGES_URL, data=json.dumps(body).encode("utf-8"), headers={ "Authorization": f"Bearer {_api_key()}", "Content-Type": "application/json", "HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game", "X-Title": "Step Line", }, method="POST", ) try: with urllib.request.urlopen(req, timeout=300) as resp: payload = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace") sys.exit(f"error: OpenRouter returned HTTP {exc.code}: {detail}") except urllib.error.URLError as exc: sys.exit(f"error: could not reach OpenRouter: {exc.reason}") data = payload.get("data") or [] if not data or not data[0].get("b64_json"): sys.exit(f"error: OpenRouter returned no image data: {payload}") raw = base64.b64decode(data[0]["b64_json"]) try: img = Image.open(io.BytesIO(raw)) img.load() except Exception as exc: # noqa: BLE001 - surface any decode failure clearly sys.exit(f"error: could not decode returned image: {exc}") return img def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image: """Downscale to fit within max_dim on the longest side, preserving aspect.""" w, h = img.size longest = max(w, h) if longest <= max_dim: return img scale = max_dim / longest return img.resize((max(1, round(w * scale)), max(1, round(h * scale))), Image.Resampling.LANCZOS) def generate_ai_textures(prompt: str, name: str, model: str, size: int, transparent: bool = False) -> None: """Generate one texture via OpenRouter and save it under assets/textures/.""" img = generate_image(prompt, model, size, transparent) img = _fit(img) if transparent: img = img.convert("RGBA") elif img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info): img = img.convert("RGBA") else: img = img.convert("RGB") save(img, name) def _run_ai(args: argparse.Namespace) -> None: if args.prompt: if not args.name: sys.exit("error: --name is required when --prompt is given") generate_ai_textures(args.prompt, args.name, args.model, args.size) return for name, prompt, transparent in AI_TEXTURES: print(f"generating {name} ...") generate_ai_textures(prompt, name, args.model, args.size, transparent) def _build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Generate Step Line textures (authored or via OpenRouter).") parser.add_argument( "--ai", action="store_true", help="generate textures through the OpenRouter image API " "(requires OPENROUTER_API_KEY)") parser.add_argument( "--prompt", default="", help="single prompt for --ai (overrides the default batch)") parser.add_argument( "--name", default="", help="output filename under assets/textures/ for a single --prompt") parser.add_argument( "--model", default=DEFAULT_IMAGE_MODEL, help=f"image model slug (default: {DEFAULT_IMAGE_MODEL})") parser.add_argument( "--size", type=int, default=GEN_SIZE, help=f"square size requested from the API in px (min {GEN_SIZE}); " f"output is downscaled to <= {MAX_DIM} (default: {GEN_SIZE})") return parser if __name__ == "__main__": args = _build_arg_parser().parse_args() if args.ai: _run_ai(args) print("done") sys.exit(0) brick_facade() seamless_concrete() asphalt() awning() skyline() vignette() icons() app_icon() cards() print("done")