Files
security-game/tools/generate_textures.py
T
zwitschi ba76c0febf
Full build / Import + headless tests (push) Successful in 17s
Full build / Full desktop build + optional release (push) Skipped
Add Windows installer (NSIS) and Linux AppImage packaging; embed PCK in all exports
- export_presets: embed_pck=true for Linux/Windows/macOS -> single self-contained
  binaries (all game data in one file); build_releases.sh cleans stale .pck
- App icon: assets/textures/icon.png (1024) + multi-size icon.ico generated by
  tools/generate_textures.py; copied into packaging/windows and packaging/linux
- packaging/windows/step-line.nsi: NSIS installer (shortcuts, uninstaller,
  registry Add/Remove Programs) output step-line-setup-<ver>.exe
- packaging/linux: step-line.desktop, AppRun, step-line.appdata.xml,
  build_appimage.sh (appimagetool, APPIMAGE_EXTRACT_AND_RUN) ->
  step-line-<ver>-x86_64.AppImage
- CI build job now packages NSIS installer + AppImage alongside binaries;
  publish glob already uploads them
- Local end-to-end verification: embedded exports rebuilt, AppImage built and
  run headless (0 script errors); suite 57/57
- README Downloads table lists installer/AppImage/portable rows; RELEASING
  documents packaging; TODO/DONE synced
2026-10-31 13:46:54 +01:00

249 lines
9.4 KiB
Python

#!/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
"""
import math
import os
import random
import stat
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")
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)
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
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)
# multi-size ICO for Windows (from the 256 render)
src = Image.open(os.path.join(ASSETS, "icon.png"))
ico_path = os.path.join(ASSETS, "icon.ico")
src.save(ico_path, format="ICO", sizes=[(256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)])
print("wrote assets/textures/icon.ico (multi-size)")
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))
if __name__ == "__main__":
brick_facade()
seamless_concrete()
asphalt()
awning()
skyline()
vignette()
icons()
app_icon()
cards()
print("done")