"""
This file contains the concrete cutscene beats gale.cutscene ships
with: Wait (a pure pause), ShowImage, PlayAnimation (a "video" -- see
its own docstring for why that's a deliberate, dependency-free
choice), MoveActor, SetActorAnimation, and Dialogue. Every one of them
is a gale.sequence.Step, so duration/advance_on_input (and
gale.sequence.StepGroup, for running several of them side by side) all
just work. Write your own Step subclasses for anything game-specific a
cutscene needs beyond these.
Author: Alejandro Mujica (aledrums@gmail.com)
"""
from typing import Any, Optional, Tuple
import pygame
from gale.animation import Animation
from gale.sequence import Step
from gale.text import render_text
[docs]
class Wait(Step):
"""
Does nothing on its own -- a pure pause. Useful inside a
gale.sequence.StepGroup to give the group itself a
duration/advance_on_input distinct from its children's, or on its
own as a beat with nothing to show ("hold on black for half a
second").
"""
pass
[docs]
class ShowImage(Step):
"""
Displays a single image (a title card, a still illustration for a
story beat, ...) until this step completes.
Usage example:
ShowImage(pygame.image.load("chapter_1.png"), duration=3.0, advance_on_input="confirm")
"""
def __init__(
self,
image: pygame.Surface,
position: Tuple[float, float] = (0, 0),
**kwargs: Any,
) -> None:
"""
:param image: The image to display.
:param position: Where to blit it. The default value is (0, 0).
"""
super().__init__(**kwargs)
self.image: pygame.Surface = image
self.position: Tuple[float, float] = position
[docs]
def render(self, surface: pygame.Surface) -> None:
surface.blit(self.image, self.position)
[docs]
class PlayAnimation(Step):
"""
Plays a gale.animation.Animation as this step's content -- gale's
stand-in for "play a video". Deliberately not real video-codec
(mp4/webm) playback: gale never depends on a decoder library
(ffmpeg, OpenCV, ...), the same way gale.tilemap never depends on
gale.physics. Pre-render the real footage as a frame sequence
(most video editors, and ffmpeg itself, can export a PNG-per-frame
sequence) and load those the same way any other sprite animation
would.
Complete once the animation runs out of loops, or via
duration/advance_on_input if it loops forever (loops=None) and
only those should end it.
Usage example:
frames = [pygame.image.load(f"intro_{i:03d}.png") for i in range(90)]
PlayAnimation(Animation(frames, time_interval=1 / 30, loops=1))
"""
def __init__(
self,
animation: Animation,
position: Tuple[float, float] = (0, 0),
**kwargs: Any,
) -> None:
"""
:param animation: The animation to play. Reset to its first frame every time this step becomes active.
:param position: Where to blit each frame. The default value is (0, 0).
"""
super().__init__(**kwargs)
self.animation: Animation = animation
self.position: Tuple[float, float] = position
[docs]
def enter(self, *args: Any, **kwargs: Any) -> None:
super().enter(*args, **kwargs)
self.animation.reset()
[docs]
def update(self, dt: float) -> None:
self.animation.update(dt)
[docs]
def render(self, surface: pygame.Surface) -> None:
surface.blit(self.animation.get_current_frame(), self.position)
[docs]
def is_complete(self) -> bool:
if (
self.animation.loops is not None
and self.animation.times_played >= self.animation.loops
):
return True
return super().is_complete()
[docs]
class MoveActor(Step):
"""
Moves actor.position linearly from wherever it is when this step
becomes active to target, over duration seconds -- the classic
"walk to the mark" cutscene beat. actor is duck-typed: any object
exposing a mutable, pygame.Vector2-compatible .position works (an
Agent, a custom Player class, ...), so this plugs into whatever
character class the game already has without gale.cutscene needing
to know its shape.
Usage example:
MoveActor(hero, target=(400, 200), duration=1.5)
"""
def __init__(
self, actor: Any, target: Tuple[float, float], duration: float, **kwargs: Any
) -> None:
"""
:param actor: Any object with a .position attribute (a pygame.Vector2, or a value pygame.Vector2(...) accepts).
:param target: The position to move actor to.
:param duration: How many seconds the move takes.
"""
super().__init__(duration=duration, **kwargs)
self.actor: Any = actor
self.target: pygame.Vector2 = pygame.Vector2(target)
self._start: pygame.Vector2 = pygame.Vector2()
[docs]
def enter(self, *args: Any, **kwargs: Any) -> None:
super().enter(*args, **kwargs)
self._start = pygame.Vector2(self.actor.position)
[docs]
def update(self, dt: float) -> None:
t = 1.0 if self.duration <= 0 else min(1.0, self.elapsed / self.duration)
self.actor.position = self._start.lerp(self.target, t)
[docs]
class SetActorAnimation(Step):
"""
Switches actor.animation to a different gale.animation.Animation
(a pose/expression change, e.g. "surprised" instead of "idle"),
then completes right away, unless duration/advance_on_input say
otherwise. actor is duck-typed the same way MoveActor's is: any
object with a settable .animation attribute.
Usage example:
SetActorAnimation(hero, surprised_animation)
"""
def __init__(self, actor: Any, animation: Animation, **kwargs: Any) -> None:
"""
:param actor: Any object with an .animation attribute.
:param animation: The animation to switch actor to. Reset to its first frame.
"""
super().__init__(**kwargs)
self.actor: Any = actor
self.animation: Animation = animation
[docs]
def enter(self, *args: Any, **kwargs: Any) -> None:
super().enter(*args, **kwargs)
self.animation.reset()
self.actor.animation = self.animation
[docs]
def is_complete(self) -> bool:
if self.duration is None and self.advance_on_input is None:
return True
return super().is_complete()
[docs]
class Dialogue(Step):
"""
Shows a speaker name and a line of text in a simple box until this
step completes -- usually via advance_on_input, the classic "press
a key to continue" dialogue box, though duration works too for an
auto-advancing line. Draws a plain rectangle behind the text by
default; override render (or subclass) for a game's own dialogue
box art/gale.ui widgets instead.
Usage example:
Dialogue("Elder", "The forest holds many secrets...", font, advance_on_input="confirm")
"""
def __init__(
self,
speaker: str,
text: str,
font: pygame.font.Font,
box_rect: Optional[pygame.Rect] = None,
text_color: Any = (255, 255, 255),
speaker_color: Any = (255, 220, 120),
box_color: Any = (20, 20, 20),
**kwargs: Any,
) -> None:
"""
:param speaker: Name shown above the line, e.g. a character's name.
:param text: The line of dialogue itself.
:param font: Font used for both speaker and text.
:param box_rect: Where to draw the dialogue box. The default value is None, using a bar across the bottom of whatever surface render() is given.
:param text_color: Color of the dialogue text.
:param speaker_color: Color of the speaker's name.
:param box_color: Fill color of the dialogue box.
"""
super().__init__(**kwargs)
self.speaker: str = speaker
self.text: str = text
self.font: pygame.font.Font = font
self.box_rect: Optional[pygame.Rect] = box_rect
self.text_color: Any = text_color
self.speaker_color: Any = speaker_color
self.box_color: Any = box_color
[docs]
def render(self, surface: pygame.Surface) -> None:
box_rect = self.box_rect
if box_rect is None:
width, height = surface.get_size()
box_rect = pygame.Rect(0, height - 90, width, 90)
pygame.draw.rect(surface, self.box_color, box_rect)
render_text(
surface,
self.speaker,
self.font,
box_rect.x + 16,
box_rect.y + 10,
self.speaker_color,
)
render_text(
surface,
self.text,
self.font,
box_rect.x + 16,
box_rect.y + 40,
self.text_color,
)