Files
org_web/tools/generate-princess-lima-assets.py
gitea-actions a0c78929b3
All checks were successful
Build Org Website / build (push) Successful in 50s
Refactor platform UI and data flow across core pages
2026-07-30 12:22:44 +01:00

142 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Generate deterministic Princess Lima tiles and local narration assets."""
from __future__ import annotations
import ctypes
import ctypes.util
import math
import random
import wave
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
IMAGE_DIR = ROOT / "assets/images/play/princess-lima"
AUDIO_DIR = ROOT / "assets/audio/princess-lima"
TILE = 32
THEMES = [
("village", "#51713f", "#41556b", "#b79861", "#6d4333"),
("forest", "#173f31", "#2f6845", "#789b52", "#234f62"),
("ruins", "#263f47", "#486468", "#8a8872", "#1b7280"),
("mountain", "#6c8094", "#b9c9d6", "#e8f0f2", "#516476"),
("camp", "#53442e", "#76603a", "#aa8051", "#683f2b"),
("fortressExterior", "#221c2c", "#40344b", "#64566f", "#2b182e"),
("fortressInterior", "#201d28", "#393342", "#74636b", "#7e312a"),
("bossArena", "#17121f", "#2d2138", "#674371", "#8e365f"),
("chamber", "#73869a", "#c9b985", "#e9dfc2", "#31558b"),
]
NARRATION = [
"Before shadow crossed the northern road, Princess Lima walked among her people, listening before she ruled and helping before she asked.",
"Then Lord Malrec descended from the Fortress of Shadows. His riders carried fear through the valleys, searching for the royal oath.",
"Lima stood between the riders and the village. Malrec could not bend her will, so he bound her in shadow and carried her beyond the mountains.",
"At dawn, a lone traveller reached the broken village. The road was dangerous, but every rescued life would become another light leading to Lima.",
]
def shade(hex_color: str, factor: float) -> tuple[int, int, int, int]:
value = hex_color.lstrip("#")
channels = [int(value[index:index + 2], 16) for index in (0, 2, 4)]
return tuple(max(0, min(255, round(channel * factor))) for channel in channels) + (255,)
def draw_tile(draw: ImageDraw.ImageDraw, x: int, y: int, colors: tuple[str, str, str, str], kind: int, seed: int) -> None:
ground, detail, path, hazard = colors
randomizer = random.Random(seed)
base = [ground, detail, path, hazard][kind]
draw.rectangle((x, y, x + TILE - 1, y + TILE - 1), fill=base)
dark = shade(base, 0.72)
light = shade(base, 1.22)
if kind == 0:
for _ in range(13):
px = x + randomizer.randrange(2, TILE - 2)
py = y + randomizer.randrange(2, TILE - 2)
draw.point((px, py), fill=light if randomizer.random() > 0.45 else dark)
draw.line((x, y + TILE - 2, x + TILE, y + TILE - 2), fill=dark, width=2)
elif kind == 1:
for row in range(0, TILE, 8):
offset = 4 if row % 16 else 0
draw.line((x, y + row, x + TILE, y + row), fill=dark)
for col in range(-offset, TILE, 12):
draw.line((x + col, y + row, x + col + 5, y + row + 4), fill=light, width=2)
elif kind == 2:
draw.line((x + 3, y + 2, x + 3, y + TILE - 3), fill=dark, width=2)
draw.line((x + TILE - 4, y + 2, x + TILE - 4, y + TILE - 3), fill=light, width=2)
for row in range(5, TILE, 8):
draw.line((x + 6, y + row, x + TILE - 7, y + row + randomizer.choice((-1, 0, 1))), fill=dark)
else:
for row in range(4, TILE, 7):
draw.arc((x - 5, y + row - 4, x + 16, y + row + 4), 195, 345, fill=light, width=2)
draw.arc((x + 13, y + row - 4, x + 34, y + row + 4), 195, 345, fill=dark, width=2)
def make_tiles() -> None:
image = Image.new("RGBA", (TILE * 4, TILE * len(THEMES)), "#000000")
draw = ImageDraw.Draw(image)
for row, (_, *colors) in enumerate(THEMES):
for kind in range(4):
draw_tile(draw, kind * TILE, row * TILE, tuple(colors), kind, row * 101 + kind)
image.save(IMAGE_DIR / "world-tiles.png", optimize=True)
def synthesize(text: str, destination: Path) -> bool:
library = ctypes.util.find_library("espeak-ng")
if not library:
return False
espeak = ctypes.CDLL(library)
samples: list[int] = []
callback_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(ctypes.c_short), ctypes.c_int, ctypes.c_void_p)
@callback_type
def callback(wav, count, _events):
if wav and count > 0:
samples.extend(wav[index] for index in range(count))
return 0
espeak.espeak_Initialize.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_char_p, ctypes.c_int]
espeak.espeak_Initialize.restype = ctypes.c_int
espeak.espeak_SetSynthCallback.argtypes = [callback_type]
espeak.espeak_SetVoiceByName.argtypes = [ctypes.c_char_p]
espeak.espeak_Synth.argtypes = [
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint, ctypes.c_int,
ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint), ctypes.c_void_p,
]
rate = espeak.espeak_Initialize(1, 0, None, 0)
if rate <= 0:
return False
espeak.espeak_SetSynthCallback(callback)
espeak.espeak_SetVoiceByName(b"en-gb")
encoded = text.encode("utf-8") + b"\0"
identifier = ctypes.c_uint(0)
espeak.espeak_Synth(encoded, len(encoded), 0, 0, 0, 1, ctypes.byref(identifier), None)
espeak.espeak_Synchronize()
if not samples:
return False
peak = max(1, max(abs(sample) for sample in samples))
scaled = [round(sample * min(1.0, 24000 / peak)) for sample in samples]
with wave.open(str(destination), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(rate)
buffer = (ctypes.c_short * len(scaled))(*scaled)
output.writeframes(bytes(buffer))
espeak.espeak_Terminate()
return True
def make_narration() -> None:
for index, line in enumerate(NARRATION, 1):
if not synthesize(line, AUDIO_DIR / f"intro-narration-{index}.wav"):
raise RuntimeError("Local espeak-ng narration synthesis was unavailable")
if __name__ == "__main__":
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
make_tiles()
make_narration()