#!/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()