Skip to content

Animation

Module: terminaltexteffects.engine.animation

Source code in terminaltexteffects/engine/animation.py
class Animation:
    def __init__(self, character: "base_character.EffectCharacter"):
        """Animation handles the animations of a character. It contains a scene_name -> Scene mapping and the active Scene. Calls to step_animation()
        progress the Scene and apply the next visual to the character.

        Args:
            character (base_character.EffectCharacter): the EffectCharacter object to animate

        Attributes:
            scenes (dict[str, Scene]): a mapping of scene IDs to Scene objects
            character (base_character.EffectCharacter): the EffectCharacter object to animate
            active_scene (Scene | None): the active Scene
            use_xterm_colors (bool): whether to convert all colors to XTerm-256 colors
            no_color (bool): whether to ignore colors
            xterm_color_map (dict[str, int]): a mapping of RGB color codes to XTerm-256 color codes
            active_scene_current_step (int): the current step in the active Scene
            current_character_visual (CharacterVisual): the current visual of the character

        Methods:
            new_scene: Creates a new Scene and adds it to the Animation.
            query_scene: Returns a Scene from the Animation.
            active_scene_is_complete: Returns whether the active scene is complete.
            set_appearance: Applies a symbol and color to the character.
            random_color: Returns a random color.
            adjust_color_brightness: Adjusts the brightness of a given color.
            _ease_animation: Returns the percentage of total distance that should be moved based on the easing function.
            step_animation: Apply the next symbol in the scene to the character.
            activate_scene: Activates a Scene.
        """
        self.scenes: dict[str, Scene] = {}
        self.character = character
        self.active_scene: Scene | None = None
        self.use_xterm_colors: bool = False
        self.no_color: bool = False
        self.xterm_color_map: dict[str, int] = {}
        self.active_scene_current_step: int = 0
        self.current_character_visual: CharacterVisual = CharacterVisual(character.input_symbol)

    def new_scene(
        self,
        *,
        is_looping: bool = False,
        sync: SyncMetric | None = None,
        ease: easing.EasingFunction | None = None,
        id: str = "",
    ) -> Scene:
        """Creates a new Scene and adds it to the Animation. If no ID is provided, a unique ID is generated.

        Args:
            id (str): Unique name for the scene. Used to query for the scene.
            is_looping (bool): Whether the scene should loop.
            sync (SyncMetric): The type of sync to use for the scene.
            ease (easing.EasingFunction): The easing function to use for the scene.

        Returns:
            Scene: the new Scene
        """
        if not id:
            found_unique = False
            current_id = len(self.scenes)
            while not found_unique:
                id = f"{len(self.scenes)}"
                if id not in self.scenes:
                    found_unique = True
                else:
                    current_id += 1

        new_scene = Scene(scene_id=id, is_looping=is_looping, sync=sync, ease=ease)
        self.scenes[id] = new_scene
        new_scene.no_color = self.no_color
        new_scene.use_xterm_colors = self.use_xterm_colors
        return new_scene

    def query_scene(self, scene_id: str) -> Scene:
        """Returns a Scene from the Animation. If the scene doesn't exist, raises a ValueError.

        Args:
            scene_id (str): the ID of the Scene

        Raises:
            ValueError: if the Scene does not exist

        Returns:
            Scene: the Scene
        """
        scene = self.scenes.get(scene_id, None)
        if not scene:
            raise ValueError(f"Scene {scene_id} does not exist.")
        return scene

    def active_scene_is_complete(self) -> bool:
        """Returns whether the active scene is complete. A scene is complete if all sequences have been played.
        Looping scenes are always complete.

        Returns:
            bool: True if complete, False otherwise
        """
        if not self.active_scene:
            return True
        elif not self.active_scene.frames or self.active_scene.is_looping:
            return True

        return False

    def set_appearance(self, symbol: str, color: graphics.Color | None = None) -> None:
        """Updates the current character visual with the symbol and color provided. If the character has an active scene, any appearance set with this method
        will be overwritten when the scene is stepped to the next frame.

        Args:
            symbol (str): The symbol to apply.
            color (graphics.Color | None): The color to apply.
        """
        char_vis_color: str | int | None = None
        if color:
            if self.no_color:
                char_vis_color = None
            elif self.use_xterm_colors:
                char_vis_color = color.xterm_color
            else:
                char_vis_color = color.rgb_color
        self.current_character_visual = CharacterVisual(symbol, color=color, _color_code=char_vis_color)

    @staticmethod
    def random_color() -> graphics.Color:
        """Returns a random color.

        Returns:
            graphics.Color: A random color.
        """
        return graphics.Color(hex(random.randint(0, 0xFFFFFF))[2:].zfill(6))

    @staticmethod
    def adjust_color_brightness(color: graphics.Color, brightness: float) -> graphics.Color:
        """
        Adjusts the brightness of a given color.

        Args:
            color (Color): The color code to adjust.
            brightness (float): The brightness adjustment factor.

        Returns:
            Color: The adjusted color code.
        """

        def hue_to_rgb(lightness_scaled: float, color_intensity: float, hue_value: float) -> float:
            """
            Converts a hue value to an RGB value component.

            This function is a helper function used in the conversion from HSL (Hue, Saturation, Lightness)
            color space to RGB (Red, Green, Blue) color space. It takes in three parameters: lightness_scaled,
            color_intensity, and hue_value. These parameters are derived from the HSL color space and are used
            to calculate the corresponding RGB value.

            Args:
                lightness_scaled (float): The lightness value from the HSL color space, scaled and shifted to be used in the RGB conversion.
                color_intensity (float): The intensity of the color, used to adjust the RGB values.
                hue_value (float): The hue value from the HSL color space, used to calculate the RGB values.

            Returns:
                float: The calculated RGB component.
            """

            if hue_value < 0:
                hue_value += 1
            if hue_value > 1:
                hue_value -= 1
            if hue_value < 1 / 6:
                return lightness_scaled + (color_intensity - lightness_scaled) * 6 * hue_value
            if hue_value < 1 / 2:
                return color_intensity
            if hue_value < 2 / 3:
                return lightness_scaled + (color_intensity - lightness_scaled) * (2 / 3 - hue_value) * 6
            return lightness_scaled

        normalized_red = int(color.rgb_color[0:2], 16) / 255
        normalized_green = int(color.rgb_color[2:4], 16) / 255
        normalized_blue = int(color.rgb_color[4:6], 16) / 255

        # Convert RGB to HSL
        max_val = max(normalized_red, normalized_green, normalized_blue)
        min_val = min(normalized_red, normalized_green, normalized_blue)
        lightness = (max_val + min_val) / 2

        if max_val == min_val:
            hue_value = saturation = 0.0  # achromatic
        else:
            diff = max_val - min_val
            saturation = diff / (2 - max_val - min_val) if lightness > 0.5 else diff / (max_val + min_val)
            if max_val == normalized_red:
                hue_value = (normalized_green - normalized_blue) / diff + (
                    6 if normalized_green < normalized_blue else 0
                )
            elif max_val == normalized_green:
                hue_value = (normalized_blue - normalized_red) / diff + 2
            else:
                hue_value = (normalized_red - normalized_green) / diff + 4
            hue_value /= 6

        # Adjust lightness
        lightness = max(min(lightness * brightness, 1), 0)

        # Convert back to RGB
        if saturation == 0:
            red = green = blue = lightness  # achromatic
        else:
            color_intensity = (
                lightness * (1 + saturation) if lightness < 0.5 else lightness + saturation - lightness * saturation
            )
            lightness_scaled = 2 * lightness - color_intensity
            red = hue_to_rgb(lightness_scaled, color_intensity, hue_value + 1 / 3)
            green = hue_to_rgb(lightness_scaled, color_intensity, hue_value)
            blue = hue_to_rgb(lightness_scaled, color_intensity, hue_value - 1 / 3)

        # Convert to hex
        adjusted_color = "{:02x}{:02x}{:02x}".format(int(red * 255), int(green * 255), int(blue * 255))
        return graphics.Color(adjusted_color)

    def _ease_animation(self, easing_func: easing.EasingFunction) -> float:
        """Returns the percentage of total distance that should be moved based on the easing function.

        Args:
            easing_func (easing.EasingFunction): The easing function to use.

        Returns:
            float: The percentage of total distance to move.
        """

        if self.active_scene is None:
            return 0
        elapsed_step_ratio = self.active_scene.easing_current_step / self.active_scene.easing_total_steps
        return easing_func(elapsed_step_ratio)

    def step_animation(self) -> None:
        """Progresses the Scene and applies the next visual to the character. If the active scene is complete, a SCENE_COMPLETE event is triggered."""
        if self.active_scene and self.active_scene.frames:
            # if the active scene is synced to movement, calculate the sequence index based on the
            # current waypoint progress
            if self.active_scene.sync:
                if self.character.motion.active_path:
                    if self.active_scene.sync == SyncMetric.STEP:
                        sequence_index = round(
                            (len(self.active_scene.frames) - 1)
                            * (
                                max(self.character.motion.active_path.current_step, 1)
                                / max(self.character.motion.active_path.max_steps, 1)
                            )
                        )
                    elif self.active_scene.sync == SyncMetric.DISTANCE:
                        sequence_index = round(
                            (len(self.active_scene.frames) - 1)
                            * (
                                max(
                                    max(self.character.motion.active_path.total_distance, 1)
                                    - max(
                                        self.character.motion.active_path.total_distance
                                        - self.character.motion.active_path.last_distance_reached,
                                        1,
                                    ),
                                    1,
                                )
                                / max(self.character.motion.active_path.total_distance, 1)
                            )
                        )
                    try:
                        self.current_character_visual = self.active_scene.frames[sequence_index].character_visual
                    except IndexError:
                        self.current_character_visual = self.active_scene.frames[-1].character_visual
                else:  # when the active waypoint has been deactivated, use the final symbol in the scene and finish the scene
                    self.current_character_visual = self.active_scene.frames[-1].character_visual
                    self.active_scene.played_frames.extend(self.active_scene.frames)
                    self.active_scene.frames.clear()

            elif self.active_scene and self.active_scene.ease:
                easing_factor = self._ease_animation(self.active_scene.ease)
                frame_index = round(easing_factor * max(self.active_scene.easing_total_steps - 1, 0))
                frame_index = max(min(frame_index, self.active_scene.easing_total_steps - 1), 0)
                frame = self.active_scene.frame_index_map[frame_index]
                self.current_character_visual = frame.character_visual
                self.active_scene.easing_current_step += 1
                if self.active_scene.easing_current_step == self.active_scene.easing_total_steps:
                    if self.active_scene.is_looping:
                        self.active_scene.easing_current_step = 0
                    else:
                        self.active_scene.played_frames.extend(self.active_scene.frames)
                        self.active_scene.frames.clear()

            else:
                self.current_character_visual = self.active_scene.get_next_visual()
            if self.active_scene_is_complete():
                completed_scene = self.active_scene
                if not self.active_scene.is_looping:
                    self.active_scene.reset_scene()
                    self.active_scene = None

                self.character.event_handler._handle_event(
                    self.character.event_handler.Event.SCENE_COMPLETE, completed_scene
                )

    def activate_scene(self, scene: Scene) -> None:
        """Sets the active scene and updates the current character visual. A SCENE_ACTIVATED event is triggered.

        Args:
            scene (Scene): the Scene to set as active
        """
        self.active_scene = scene
        self.active_scene_current_step = 0
        self.current_character_visual = self.active_scene.activate()
        self.character.event_handler._handle_event(self.character.event_handler.Event.SCENE_ACTIVATED, scene)

    def deactivate_scene(self, scene: Scene) -> None:
        """Deactivates a scene.

        Args:
            scene (Scene): the Scene to deactivate
        """
        if self.active_scene is scene:
            self.active_scene = None

__init__(character)

Animation handles the animations of a character. It contains a scene_name -> Scene mapping and the active Scene. Calls to step_animation() progress the Scene and apply the next visual to the character.

Parameters:

Name Type Description Default
character EffectCharacter

the EffectCharacter object to animate

required

Attributes:

Name Type Description
scenes dict[str, Scene]

a mapping of scene IDs to Scene objects

character EffectCharacter

the EffectCharacter object to animate

active_scene Scene | None

the active Scene

use_xterm_colors bool

whether to convert all colors to XTerm-256 colors

no_color bool

whether to ignore colors

xterm_color_map dict[str, int]

a mapping of RGB color codes to XTerm-256 color codes

active_scene_current_step int

the current step in the active Scene

current_character_visual CharacterVisual

the current visual of the character

Functions:

Name Description
new_scene

Creates a new Scene and adds it to the Animation.

query_scene

Returns a Scene from the Animation.

active_scene_is_complete

Returns whether the active scene is complete.

set_appearance

Applies a symbol and color to the character.

random_color

Returns a random color.

adjust_color_brightness

Adjusts the brightness of a given color.

_ease_animation

Returns the percentage of total distance that should be moved based on the easing function.

step_animation

Apply the next symbol in the scene to the character.

activate_scene

Activates a Scene.

Source code in terminaltexteffects/engine/animation.py
def __init__(self, character: "base_character.EffectCharacter"):
    """Animation handles the animations of a character. It contains a scene_name -> Scene mapping and the active Scene. Calls to step_animation()
    progress the Scene and apply the next visual to the character.

    Args:
        character (base_character.EffectCharacter): the EffectCharacter object to animate

    Attributes:
        scenes (dict[str, Scene]): a mapping of scene IDs to Scene objects
        character (base_character.EffectCharacter): the EffectCharacter object to animate
        active_scene (Scene | None): the active Scene
        use_xterm_colors (bool): whether to convert all colors to XTerm-256 colors
        no_color (bool): whether to ignore colors
        xterm_color_map (dict[str, int]): a mapping of RGB color codes to XTerm-256 color codes
        active_scene_current_step (int): the current step in the active Scene
        current_character_visual (CharacterVisual): the current visual of the character

    Methods:
        new_scene: Creates a new Scene and adds it to the Animation.
        query_scene: Returns a Scene from the Animation.
        active_scene_is_complete: Returns whether the active scene is complete.
        set_appearance: Applies a symbol and color to the character.
        random_color: Returns a random color.
        adjust_color_brightness: Adjusts the brightness of a given color.
        _ease_animation: Returns the percentage of total distance that should be moved based on the easing function.
        step_animation: Apply the next symbol in the scene to the character.
        activate_scene: Activates a Scene.
    """
    self.scenes: dict[str, Scene] = {}
    self.character = character
    self.active_scene: Scene | None = None
    self.use_xterm_colors: bool = False
    self.no_color: bool = False
    self.xterm_color_map: dict[str, int] = {}
    self.active_scene_current_step: int = 0
    self.current_character_visual: CharacterVisual = CharacterVisual(character.input_symbol)

activate_scene(scene)

Sets the active scene and updates the current character visual. A SCENE_ACTIVATED event is triggered.

Parameters:

Name Type Description Default
scene Scene

the Scene to set as active

required
Source code in terminaltexteffects/engine/animation.py
def activate_scene(self, scene: Scene) -> None:
    """Sets the active scene and updates the current character visual. A SCENE_ACTIVATED event is triggered.

    Args:
        scene (Scene): the Scene to set as active
    """
    self.active_scene = scene
    self.active_scene_current_step = 0
    self.current_character_visual = self.active_scene.activate()
    self.character.event_handler._handle_event(self.character.event_handler.Event.SCENE_ACTIVATED, scene)

active_scene_is_complete()

Returns whether the active scene is complete. A scene is complete if all sequences have been played. Looping scenes are always complete.

Returns:

Name Type Description
bool bool

True if complete, False otherwise

Source code in terminaltexteffects/engine/animation.py
def active_scene_is_complete(self) -> bool:
    """Returns whether the active scene is complete. A scene is complete if all sequences have been played.
    Looping scenes are always complete.

    Returns:
        bool: True if complete, False otherwise
    """
    if not self.active_scene:
        return True
    elif not self.active_scene.frames or self.active_scene.is_looping:
        return True

    return False

adjust_color_brightness(color, brightness) staticmethod

Adjusts the brightness of a given color.

Parameters:

Name Type Description Default
color Color

The color code to adjust.

required
brightness float

The brightness adjustment factor.

required

Returns:

Name Type Description
Color Color

The adjusted color code.

Source code in terminaltexteffects/engine/animation.py
@staticmethod
def adjust_color_brightness(color: graphics.Color, brightness: float) -> graphics.Color:
    """
    Adjusts the brightness of a given color.

    Args:
        color (Color): The color code to adjust.
        brightness (float): The brightness adjustment factor.

    Returns:
        Color: The adjusted color code.
    """

    def hue_to_rgb(lightness_scaled: float, color_intensity: float, hue_value: float) -> float:
        """
        Converts a hue value to an RGB value component.

        This function is a helper function used in the conversion from HSL (Hue, Saturation, Lightness)
        color space to RGB (Red, Green, Blue) color space. It takes in three parameters: lightness_scaled,
        color_intensity, and hue_value. These parameters are derived from the HSL color space and are used
        to calculate the corresponding RGB value.

        Args:
            lightness_scaled (float): The lightness value from the HSL color space, scaled and shifted to be used in the RGB conversion.
            color_intensity (float): The intensity of the color, used to adjust the RGB values.
            hue_value (float): The hue value from the HSL color space, used to calculate the RGB values.

        Returns:
            float: The calculated RGB component.
        """

        if hue_value < 0:
            hue_value += 1
        if hue_value > 1:
            hue_value -= 1
        if hue_value < 1 / 6:
            return lightness_scaled + (color_intensity - lightness_scaled) * 6 * hue_value
        if hue_value < 1 / 2:
            return color_intensity
        if hue_value < 2 / 3:
            return lightness_scaled + (color_intensity - lightness_scaled) * (2 / 3 - hue_value) * 6
        return lightness_scaled

    normalized_red = int(color.rgb_color[0:2], 16) / 255
    normalized_green = int(color.rgb_color[2:4], 16) / 255
    normalized_blue = int(color.rgb_color[4:6], 16) / 255

    # Convert RGB to HSL
    max_val = max(normalized_red, normalized_green, normalized_blue)
    min_val = min(normalized_red, normalized_green, normalized_blue)
    lightness = (max_val + min_val) / 2

    if max_val == min_val:
        hue_value = saturation = 0.0  # achromatic
    else:
        diff = max_val - min_val
        saturation = diff / (2 - max_val - min_val) if lightness > 0.5 else diff / (max_val + min_val)
        if max_val == normalized_red:
            hue_value = (normalized_green - normalized_blue) / diff + (
                6 if normalized_green < normalized_blue else 0
            )
        elif max_val == normalized_green:
            hue_value = (normalized_blue - normalized_red) / diff + 2
        else:
            hue_value = (normalized_red - normalized_green) / diff + 4
        hue_value /= 6

    # Adjust lightness
    lightness = max(min(lightness * brightness, 1), 0)

    # Convert back to RGB
    if saturation == 0:
        red = green = blue = lightness  # achromatic
    else:
        color_intensity = (
            lightness * (1 + saturation) if lightness < 0.5 else lightness + saturation - lightness * saturation
        )
        lightness_scaled = 2 * lightness - color_intensity
        red = hue_to_rgb(lightness_scaled, color_intensity, hue_value + 1 / 3)
        green = hue_to_rgb(lightness_scaled, color_intensity, hue_value)
        blue = hue_to_rgb(lightness_scaled, color_intensity, hue_value - 1 / 3)

    # Convert to hex
    adjusted_color = "{:02x}{:02x}{:02x}".format(int(red * 255), int(green * 255), int(blue * 255))
    return graphics.Color(adjusted_color)

deactivate_scene(scene)

Deactivates a scene.

Parameters:

Name Type Description Default
scene Scene

the Scene to deactivate

required
Source code in terminaltexteffects/engine/animation.py
def deactivate_scene(self, scene: Scene) -> None:
    """Deactivates a scene.

    Args:
        scene (Scene): the Scene to deactivate
    """
    if self.active_scene is scene:
        self.active_scene = None

new_scene(*, is_looping=False, sync=None, ease=None, id='')

Creates a new Scene and adds it to the Animation. If no ID is provided, a unique ID is generated.

Parameters:

Name Type Description Default
id str

Unique name for the scene. Used to query for the scene.

''
is_looping bool

Whether the scene should loop.

False
sync SyncMetric

The type of sync to use for the scene.

None
ease EasingFunction

The easing function to use for the scene.

None

Returns:

Name Type Description
Scene Scene

the new Scene

Source code in terminaltexteffects/engine/animation.py
def new_scene(
    self,
    *,
    is_looping: bool = False,
    sync: SyncMetric | None = None,
    ease: easing.EasingFunction | None = None,
    id: str = "",
) -> Scene:
    """Creates a new Scene and adds it to the Animation. If no ID is provided, a unique ID is generated.

    Args:
        id (str): Unique name for the scene. Used to query for the scene.
        is_looping (bool): Whether the scene should loop.
        sync (SyncMetric): The type of sync to use for the scene.
        ease (easing.EasingFunction): The easing function to use for the scene.

    Returns:
        Scene: the new Scene
    """
    if not id:
        found_unique = False
        current_id = len(self.scenes)
        while not found_unique:
            id = f"{len(self.scenes)}"
            if id not in self.scenes:
                found_unique = True
            else:
                current_id += 1

    new_scene = Scene(scene_id=id, is_looping=is_looping, sync=sync, ease=ease)
    self.scenes[id] = new_scene
    new_scene.no_color = self.no_color
    new_scene.use_xterm_colors = self.use_xterm_colors
    return new_scene

query_scene(scene_id)

Returns a Scene from the Animation. If the scene doesn't exist, raises a ValueError.

Parameters:

Name Type Description Default
scene_id str

the ID of the Scene

required

Raises:

Type Description
ValueError

if the Scene does not exist

Returns:

Name Type Description
Scene Scene

the Scene

Source code in terminaltexteffects/engine/animation.py
def query_scene(self, scene_id: str) -> Scene:
    """Returns a Scene from the Animation. If the scene doesn't exist, raises a ValueError.

    Args:
        scene_id (str): the ID of the Scene

    Raises:
        ValueError: if the Scene does not exist

    Returns:
        Scene: the Scene
    """
    scene = self.scenes.get(scene_id, None)
    if not scene:
        raise ValueError(f"Scene {scene_id} does not exist.")
    return scene

random_color() staticmethod

Returns a random color.

Returns:

Type Description
Color

graphics.Color: A random color.

Source code in terminaltexteffects/engine/animation.py
@staticmethod
def random_color() -> graphics.Color:
    """Returns a random color.

    Returns:
        graphics.Color: A random color.
    """
    return graphics.Color(hex(random.randint(0, 0xFFFFFF))[2:].zfill(6))

set_appearance(symbol, color=None)

Updates the current character visual with the symbol and color provided. If the character has an active scene, any appearance set with this method will be overwritten when the scene is stepped to the next frame.

Parameters:

Name Type Description Default
symbol str

The symbol to apply.

required
color Color | None

The color to apply.

None
Source code in terminaltexteffects/engine/animation.py
def set_appearance(self, symbol: str, color: graphics.Color | None = None) -> None:
    """Updates the current character visual with the symbol and color provided. If the character has an active scene, any appearance set with this method
    will be overwritten when the scene is stepped to the next frame.

    Args:
        symbol (str): The symbol to apply.
        color (graphics.Color | None): The color to apply.
    """
    char_vis_color: str | int | None = None
    if color:
        if self.no_color:
            char_vis_color = None
        elif self.use_xterm_colors:
            char_vis_color = color.xterm_color
        else:
            char_vis_color = color.rgb_color
    self.current_character_visual = CharacterVisual(symbol, color=color, _color_code=char_vis_color)

step_animation()

Progresses the Scene and applies the next visual to the character. If the active scene is complete, a SCENE_COMPLETE event is triggered.

Source code in terminaltexteffects/engine/animation.py
def step_animation(self) -> None:
    """Progresses the Scene and applies the next visual to the character. If the active scene is complete, a SCENE_COMPLETE event is triggered."""
    if self.active_scene and self.active_scene.frames:
        # if the active scene is synced to movement, calculate the sequence index based on the
        # current waypoint progress
        if self.active_scene.sync:
            if self.character.motion.active_path:
                if self.active_scene.sync == SyncMetric.STEP:
                    sequence_index = round(
                        (len(self.active_scene.frames) - 1)
                        * (
                            max(self.character.motion.active_path.current_step, 1)
                            / max(self.character.motion.active_path.max_steps, 1)
                        )
                    )
                elif self.active_scene.sync == SyncMetric.DISTANCE:
                    sequence_index = round(
                        (len(self.active_scene.frames) - 1)
                        * (
                            max(
                                max(self.character.motion.active_path.total_distance, 1)
                                - max(
                                    self.character.motion.active_path.total_distance
                                    - self.character.motion.active_path.last_distance_reached,
                                    1,
                                ),
                                1,
                            )
                            / max(self.character.motion.active_path.total_distance, 1)
                        )
                    )
                try:
                    self.current_character_visual = self.active_scene.frames[sequence_index].character_visual
                except IndexError:
                    self.current_character_visual = self.active_scene.frames[-1].character_visual
            else:  # when the active waypoint has been deactivated, use the final symbol in the scene and finish the scene
                self.current_character_visual = self.active_scene.frames[-1].character_visual
                self.active_scene.played_frames.extend(self.active_scene.frames)
                self.active_scene.frames.clear()

        elif self.active_scene and self.active_scene.ease:
            easing_factor = self._ease_animation(self.active_scene.ease)
            frame_index = round(easing_factor * max(self.active_scene.easing_total_steps - 1, 0))
            frame_index = max(min(frame_index, self.active_scene.easing_total_steps - 1), 0)
            frame = self.active_scene.frame_index_map[frame_index]
            self.current_character_visual = frame.character_visual
            self.active_scene.easing_current_step += 1
            if self.active_scene.easing_current_step == self.active_scene.easing_total_steps:
                if self.active_scene.is_looping:
                    self.active_scene.easing_current_step = 0
                else:
                    self.active_scene.played_frames.extend(self.active_scene.frames)
                    self.active_scene.frames.clear()

        else:
            self.current_character_visual = self.active_scene.get_next_visual()
        if self.active_scene_is_complete():
            completed_scene = self.active_scene
            if not self.active_scene.is_looping:
                self.active_scene.reset_scene()
                self.active_scene = None

            self.character.event_handler._handle_event(
                self.character.event_handler.Event.SCENE_COMPLETE, completed_scene
            )