142 lines
5.9 KiB
Python
Executable File
142 lines
5.9 KiB
Python
Executable File
#!/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()
|