AGofficial's picture
Upload 96 files
e95022e verified
Raw
History Blame Contribute Delete
9.59 kB
"""Pillow renderer used by the Tkinter window."""
from __future__ import annotations
from collections.abc import Iterable
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from .actions import Position
from .character import CharacterSprite
from .config import EngineConfig
from .dialogue import DialogueState
class SceneRenderer:
def __init__(self, config: EngineConfig) -> None:
self.config = config
self._stage_cache_key: tuple[object, ...] | None = None
self._stage_cache_image: Image.Image | None = None
self._font_cache: dict[tuple[int, bool], ImageFont.ImageFont] = {}
def compose(
self,
size: tuple[int, int],
background: Image.Image | None,
characters: Iterable[CharacterSprite],
dialogue: DialogueState,
) -> Image.Image:
width, height = size
frame = self._stage(size, background, tuple(characters)).copy()
self._draw_dialogue(frame, dialogue)
return frame.convert("RGB")
def _stage(
self,
size: tuple[int, int],
background: Image.Image | None,
characters: tuple[CharacterSprite, ...],
) -> Image.Image:
width, height = size
key = (
width,
height,
id(background),
tuple((char.tag, id(char.image), char.position) for char in characters),
)
if key == self._stage_cache_key and self._stage_cache_image is not None:
return self._stage_cache_image
frame = Image.new("RGBA", size, (19, 21, 26, 255))
if background is not None:
frame.alpha_composite(self._cover(background, size), (0, 0))
for character in characters:
self._paste_character(frame, character)
self._stage_cache_key = key
self._stage_cache_image = frame
return frame
def _cover(self, image: Image.Image, size: tuple[int, int]) -> Image.Image:
width, height = size
scale = max(width / image.width, height / image.height)
new_size = (max(1, int(image.width * scale)), max(1, int(image.height * scale)))
resized = image.resize(new_size, Image.Resampling.LANCZOS)
left = (resized.width - width) // 2
top = (resized.height - height) // 2
return resized.crop((left, top, left + width, top + height))
def _paste_character(self, frame: Image.Image, character: CharacterSprite) -> None:
width, height = frame.size
max_height = int(height * self.config.character_max_height_ratio)
max_width = int(width * self.config.character_max_width_ratio)
scale = min(max_height / character.image.height, max_width / character.image.width)
new_size = (
max(1, int(character.image.width * scale)),
max(1, int(character.image.height * scale)),
)
sprite = character.image.resize(new_size, Image.Resampling.LANCZOS)
anchor_x = self._position_to_x(character.position, width)
left = int(anchor_x - sprite.width / 2)
left = max(-sprite.width // 3, min(width - sprite.width * 2 // 3, left))
top = height - sprite.height
frame.alpha_composite(sprite, (left, top))
@staticmethod
def _position_to_x(position: Position, width: int) -> float:
if isinstance(position, float):
return width * position
if position == "left":
return width * 0.28
if position == "right":
return width * 0.72
return width * 0.5
def _draw_dialogue(self, frame: Image.Image, dialogue: DialogueState) -> None:
if dialogue.mode not in {"say", "input", "loading"}:
return
draw = ImageDraw.Draw(frame, "RGBA")
width, height = frame.size
margin = min(self.config.dialogue_margin, max(8, width // 30))
panel_height = min(self.config.dialogue_height, height - margin * 2)
left = margin
top = height - panel_height - margin
right = width - margin
bottom = height - margin
draw.rounded_rectangle(
(left, top, right, bottom),
radius=self.config.dialogue_radius,
fill=(0, 0, 0, self.config.dialogue_alpha),
)
name_font = self._font(self.config.name_font_size, bold=True)
text_font = self._font(self.config.text_font_size, bold=False)
prompt_font = self._font(self.config.prompt_font_size, bold=False)
pad_x = self.config.dialogue_padding_x
pad_y = self.config.dialogue_padding_y
text_left = left + pad_x
text_right = right - pad_x
name_y = top + pad_y
body_y = name_y + self.config.name_font_size + 14
if dialogue.speaker:
draw.text((text_left, name_y), dialogue.speaker, font=name_font, fill=(142, 220, 255, 255))
if dialogue.mode == "input":
if dialogue.prompt:
prompt_bottom = self._draw_wrapped(
draw,
dialogue.prompt,
prompt_font,
(232, 237, 243, 235),
(text_left, body_y, text_right, bottom - 62),
)
else:
prompt_bottom = body_y
self._draw_wrapped(
draw,
dialogue.display_input_with_caret(),
text_font,
(255, 255, 255, 255),
(text_left, prompt_bottom + 12, text_right, bottom - pad_y),
)
return
if dialogue.mode == "loading":
self._draw_wrapped(
draw,
dialogue.visible_text,
text_font,
(255, 255, 255, 255),
(text_left, body_y, text_right, bottom - pad_y),
)
return
self._draw_wrapped(
draw,
dialogue.visible_text,
text_font,
(255, 255, 255, 255),
(text_left, body_y, text_right, bottom - pad_y),
)
if dialogue.is_complete and not self.config.auto_advance:
indicator_font = self._font(24, bold=True)
draw.text((right - 48, bottom - 42), ">", font=indicator_font, fill=(255, 255, 255, 220))
def _draw_wrapped(
self,
draw: ImageDraw.ImageDraw,
text: str,
font: ImageFont.ImageFont,
fill: tuple[int, int, int, int],
box: tuple[int, int, int, int],
) -> int:
left, top, right, bottom = box
line_height = self._line_height(font) + self.config.line_spacing
lines = self._wrap_text(draw, text, font, max(1, right - left))
max_lines = max(1, (bottom - top) // line_height)
if len(lines) > max_lines:
lines = lines[-max_lines:]
y = top
for line in lines:
if y + line_height > bottom + self.config.line_spacing:
break
draw.text((left, y), line, font=font, fill=fill)
y += line_height
return y
def _wrap_text(
self,
draw: ImageDraw.ImageDraw,
text: str,
font: ImageFont.ImageFont,
max_width: int,
) -> list[str]:
if not text:
return [""]
lines: list[str] = []
for raw_line in text.splitlines() or [""]:
words = raw_line.split(" ")
current = ""
for word in words:
candidate = word if not current else f"{current} {word}"
if self._text_width(draw, candidate, font) <= max_width:
current = candidate
continue
if current:
lines.append(current)
current = self._break_long_word(draw, word, font, max_width, lines)
lines.append(current)
return lines
def _break_long_word(
self,
draw: ImageDraw.ImageDraw,
word: str,
font: ImageFont.ImageFont,
max_width: int,
lines: list[str],
) -> str:
current = ""
for char in word:
candidate = current + char
if self._text_width(draw, candidate, font) <= max_width:
current = candidate
else:
if current:
lines.append(current)
current = char
return current
@staticmethod
def _text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int:
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
@staticmethod
def _line_height(font: ImageFont.ImageFont) -> int:
bbox = font.getbbox("Ag")
return bbox[3] - bbox[1]
def _font(self, size: int, bold: bool) -> ImageFont.ImageFont:
key = (size, bold)
if key in self._font_cache:
return self._font_cache[key]
font_names = (
("segoeuib.ttf", "arialbd.ttf") if bold else ("segoeui.ttf", "arial.ttf")
)
candidates: list[str | Path] = []
windows_fonts = Path("C:/Windows/Fonts")
for name in font_names:
candidates.append(name)
candidates.append(windows_fonts / name)
for candidate in candidates:
try:
font = ImageFont.truetype(str(candidate), size=size)
self._font_cache[key] = font
return font
except OSError:
continue
font = ImageFont.load_default()
self._font_cache[key] = font
return font