Refactor platform UI and data flow across core pages
All checks were successful
Build Org Website / build (push) Successful in 50s

This commit is contained in:
gitea-actions
2026-07-30 12:22:44 +01:00
parent fe8acac707
commit a0c78929b3
45 changed files with 1168 additions and 348 deletions

View File

@@ -0,0 +1,141 @@
#!/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()