"""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