In this lesson, we will build the character display system. It will allow us to animate characters to enter or leave the scene and display their portrait either on the left or the right side.
There are two goals with this part:
By creating a new scene, we can work on the character display and test the system in isolation from the rest of the game.
To store the data related to a given character in the project, we will use Godot’s resources. They allow us to define data fields we can edit in the inspector and to define functions related to that data.
You’ll see in a second how that helps us get an expression image for our character.
To learn more about resources, check out the dedicated guide in the Best Practices chapter.
In the FileSystem dock, right-click on the Characters/
directory and create a New Script named Character.gd
, then, open it.
In this script, we define the characters’ data fields and some functions to get an image texture corresponding to an expression.
Every character should have a default image available if we try to access their textures. The main exception would be the narrator, which is a unique character.
## Data container for characters. class_name Character extends Resource ## Key through which we'll access and refer to this character in code. export var id := "character_id" ## The character's name as displayed in the text box. export var display_name := "Display Name" # We add a field to save the character's description, which you could use, for example, in a player journal or codex. # The export hint in parentheses makes the box to enter text larger in the inspector. export (String, MULTILINE) var bio := "Fill this with the character's complete bio. Supports BBCode." # We use a setter function to ensure the character's age is always a positive number. export var age := 0 setget set_age ## Default key to use when accessing the `images` dictionary below if the user ## doesn't specify the image to display. export var default_image := "neutral" ## Holds the character's portraits, mapping expressions (keys) to an image texture. export var images := { neutral = null, } func _init() -> void: assert(default_image in images) func get_default_image() -> Texture: return images[default_image] func get_image(expression: String) -> Texture: return images.get(expression, get_default_image()) func set_age(value: int) -> void: age = int(min(value, 0))
The main notable bit is the get_image()
function. To access the images
dictionary, we use the Dictionary.get()
method. It allows us to try to access a key safely, and if it doesn’t exist, the function returns the second argument. In this case, it’s the default image for this character.
With that, we can create two character resources to test the display system we’ll create next. Note you must save the script first to create new Character
resources.
We included two character illustrations in the Characters/
directory: Dani.png
and Sophia.png
. You can use them to create two test characters.
Right-click in the FileSystem and create a New Resource. Look for our newly added Character resource type and name it sophia.tres
. Double-click it and in the Inspector, set the Id to sophia
, the name to Sophia
, and we’ll set an image next.
Click the field next to Images to expand the dictionary. You should see a key named neutral and a filled marked as [null] next to it. Click the pencil icon to the right to set the value type and select Object in the drop-down. That’s the type we want to be able to assign resources to the field. Then, you can drag and drop Sophia.png
onto it.
You can repeat the steps to create a second resource for the other character, Dani. Its Id should be dani
to match the rest of this series.
Let’s create the character display’s scene. Create a new 2D Scene to get started.
It is comprised of a Node2D
as the root that we name CharacterDisplayer, two Sprite nodes, and a Tween node to animate the sprites. I called the sprites respectively Left and Right, and positioned them on either side of the screen.
I invite you to assign a portrait to each of them. You can use the two character portraits included in the project, Dani.png
and Sophia.png
. Then, you’ll want to set the Right sprite’s Offset -> Flip H to true
to mirror the character.
The idea is that most characters in your game should be about the same size, so you want to position those sprites so they work well even with the text box in front of them. You can create a temporary instance of the text box in the scene to preview the result.
The sprites’ position will be where the characters arrive when animating them entering the scene.
To control that, we will write two functions using the tween node. But first, we need to write code to display a character on the left or on the right.
Designers and writers will define scenes by writing some data, like which character should appear, speak, with which facial expression, etc.
We want our CharacterDisplayer to provide the necessary function(s) to support all the options we want to give to the designers later on.
To do so, you could either use multiple functions, each with a specific purpose, or one function with quite a few parameters. I went for the second option as it keeps all the display code in one place, and the final code is short enough.
Attach a new script to the CharacterDisplayer and let’s start with the usual signals and properties.
## Displays and animates [Character] portraits, for example, entering from the ## left or the right. Place it behind a [TextBox]. class_name CharacterDisplayer extends Node ## Emitted when the characters finished displaying or finished their animation. signal display_finished ## We define a constant to select a valid side to display the characters, and it ## maps to the keys of our _displayed variable below. const SIDE := {LEFT = "left", RIGHT = "right"} ## We'll use this color to animate characters fading in and out. const COLOR_WHITE_TRANSPARENT = Color(1.0, 1.0, 1.0, 0.0) var _displayed := {left = null, right = null} onready var _tween: Tween = $Tween onready var _left_sprite: Sprite = $Left onready var _right_sprite: Sprite = $Right
The _displayed
variable allows us to keep track of the character displayed on either side. That way, we can ensure we never change a character’s side inadvertently at runtime, which would look really off.
You could also use that information to automatically animate the characters going in and out of the scene.
At the start of the game, we also hide both sprites and connect to the tween’s tween_all_completed
signal. When all tweens complete, we emit the display_finished
signal. As with the text box, we will use that signal to coordinate the different UI components’ states in our scene player.
func _ready() -> void: # We hide both sprites to avoid showing unintended portraits at the start of # the game. _left_sprite.hide() _right_sprite.hide() # The tween node will come into play in a moment when we add the animation # functions. _tween.connect("tween_all_completed", self, "_on_Tween_tween_all_completed") func _on_Tween_tween_all_completed() -> void: emit_signal("display_finished")
Here is the display()
function. We give the option to display a given character on a certain side, with a given expression, and playing a specific animation.
The main part of it is figuring out on which side we want to display the character. When there are none, it’s easy. It’s the side the user requested. But if a character is already displayed on one side, we don’t want to have the character jump to the other side and replace another one, even if the designer made a mistake.
# Displays a character on either the left or the right side of the screen, # optionally playing an animation. func display(character: Character, side: String = SIDE.LEFT, expression := "", animation := "") -> void: # This assertion is here to raise an error if we pass an invalid side value. assert(side in SIDE.values()) # We have to figure out if a want to modify the left or the right sprite in # our scene. We start by picking a node based on the side value. var sprite: Sprite = _left_sprite if side == SIDE.LEFT else _right_sprite # Then, we keep track of the side that already has a character. If we are # already displaying this character, we ensure that we use the corresponding # sprite. if character == _displayed.left: sprite = _left_sprite elif character == _displayed.right: sprite = _right_sprite # If we haven't display that character yet, we then store the side on which # it is showing up. else: _displayed[side] = character # We then update the sprite's texture, using the character's expression, and # execute an animation function if applicable. sprite.texture = character.get_image(expression) sprite.show()
We could already test the code here, but the code’s not interesting without animation, so let’s add that first.
For each animation, we’ll define a new function. We’ll design two here: _enter()
and _leave()
. We’ll give each an id to map an animation name in the future scene’s data to a function, and call the corresponding function from display()
.
To that end, we define a new constant.
## Maps animation text ids to a function that animates a character sprite. const ANIMATIONS := {"enter": "_enter", "leave": "_leave"} func display(character: Character, side: String = SIDE.LEFT, expression := "", animation := "") -> void: #... if animation != "": # `call()` calls the function corresponding to the first argument and passes the other arguments to it. call(ANIMATIONS[animation], side, sprite)
The animations work by starting at an offset from the side the character should appear. We then calculate the start and position of the tween, and call Tween.interpolate_property()
to play that animation with a quintic transition.
We also animate the sprite’s modulate property, so it fades in, making it feel like it is appearing and entering the scene.
Here’s the “enter” animation.
## Fades in and moves the character to the anchor position. func _enter(from_side: String, sprite: Sprite) -> void: var offset := -200 if from_side == SIDE.LEFT else 200 var start := sprite.position + Vector2(offset, 0.0) var end := sprite.position # Here's the position animation. _tween.interpolate_property( sprite, "position", start, end, 0.5, Tween.TRANS_QUINT, Tween.EASE_OUT ) # And the opacity one. _tween.interpolate_property(sprite, "modulate", COLOR_WHITE_TRANSPARENT, Color.white, 0.25) _tween.start() # In some parts of the course, we will use the `Tween.seek()` method to # initialize the animation. # Here, though, it can cause visual artifacts when the player repeatedly # presses the enter button fast enough, which will skip the animation. # So we need to manually set relevant properties to start the animation # instantly. sprite.position = start sprite.modulate = COLOR_WHITE_TRANSPARENT
The leave animation is almost a copy of the enter one, except the timings and the position order are different. I recommend creating separate functions for animations, even if they seem similar. Trying to fit multiple animations into one function takes quite a bit of extra logic, making it harder to change and read later on.
With separate functions, you can very easily update an animation and its timings.
func _leave(from_side: String, sprite: Sprite) -> void: var offset := -200 if from_side == SIDE.LEFT else 200 var start := sprite.position var end := sprite.position + Vector2(offset, 0.0) _tween.interpolate_property( sprite, "position", start, end, 0.5, Tween.TRANS_QUINT, Tween.EASE_OUT ) _tween.interpolate_property( sprite, "modulate", Color.white, COLOR_WHITE_TRANSPARENT, 0.25, Tween.TRANS_LINEAR, Tween.EASE_OUT, 0.25 ) _tween.start() _tween.seek(0.0)
We’ll add one last bit: if the player presses enter in the game, it should advance animations to the end. We coded that on the TextBox
, and we should add that to the CharacterDisplayer
too.
Following object-oriented programming practices, we let every node handle its input if possible. Although you could choose to have a higher level system advance both the text box and the character animation when the player presses enter.
func _unhandled_input(event: InputEvent) -> void: # If the player presses enter before the character animations ended, we seek # to the end. if event.is_action_pressed("ui_accept") and _tween.is_active(): _tween.seek(INF)
We can sequence a test animation with a few calls to yield()
and display()
in the _ready()
function.
To do so, we have to load the character resources we created at the start of the lesson.
func _ready() -> void: #... # You can preload resources, that is to say, have the engine load them when # parsing your GDScript code, before the game runs. var characters_test = [ preload("res://Characters/dani.tres"), preload("res://Characters/sophia.tres") ] # Wait a bit so the window is visible before the animation start. yield(get_tree().create_timer(0.5), "timeout") # The two characters appear one after the other. display(characters_test[0], SIDE.LEFT, "", "enter") yield(self, "display_finished") display(characters_test[1], SIDE.RIGHT, "", "enter") # After one second, the first character leaves, then the other. yield(get_tree().create_timer(1.0), "timeout") display(characters_test[0], SIDE.LEFT, "", "leave") yield(get_tree().create_timer(1.0), "timeout") display(characters_test[1], SIDE.RIGHT, "", "leave")
After ensuring the character displayed alright, you can remove that test code.
Here are the two scripts we wrote in this lesson.
Character.gd
class_name Character extends Resource export var id := "character_id" export var display_name := "Display Name" export (String, MULTILINE) var bio := "Fill this with the character's complete bio. Supports BBCode." export var age := 0 setget set_age export var default_image := "neutral" export var images := { neutral = null, } func _init() -> void: assert(default_image in images) func get_default_image() -> Texture: return images[default_image] func get_image(expression: String) -> Texture: return images.get(expression, get_default_image()) func set_age(value: int) -> void: age = int(min(value, 0))
CharacterDisplayer.gd
class_name CharacterDisplayer extends Node signal display_finished const SIDE := {LEFT = "left", RIGHT = "right"} const COLOR_WHITE_TRANSPARENT = Color(1.0, 1.0, 1.0, 0.0) const ANIMATIONS := {"enter": "_enter", "leave": "_leave"} var _displayed := {left = null, right = null} onready var _tween: Tween = $Tween onready var _left_sprite: Sprite = $Left onready var _right_sprite: Sprite = $Right func _ready() -> void: _left_sprite.hide() _right_sprite.hide() _tween.connect("tween_all_completed", self, "_on_Tween_tween_all_completed") func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_accept") and _tween.is_active(): _tween.seek(INF) func display(character: Character, side: String = SIDE.LEFT, expression := "", animation := "") -> void: assert(side in SIDE.values()) var sprite: Sprite = _left_sprite if side == SIDE.LEFT else _right_sprite if character == _displayed.left: sprite = _left_sprite elif character == _displayed.right: sprite = _right_sprite else: _displayed[side] = character sprite.texture = character.get_image(expression) sprite.show() if animation != "": call(ANIMATIONS[animation], side, sprite) func _on_Tween_tween_all_completed() -> void: emit_signal("display_finished") func _enter(from_side: String, sprite: Sprite) -> void: var offset := -200 if from_side == SIDE.LEFT else 200 var start := sprite.position + Vector2(offset, 0.0) var end := sprite.position _tween.interpolate_property( sprite, "position", start, end, 0.5, Tween.TRANS_QUINT, Tween.EASE_OUT ) _tween.interpolate_property(sprite, "modulate", COLOR_WHITE_TRANSPARENT, Color.white, 0.25) _tween.start() sprite.position = start sprite.modulate = COLOR_WHITE_TRANSPARENT func _leave(from_side: String, sprite: Sprite) -> void: var offset := -200 if from_side == SIDE.LEFT else 200 var start := sprite.position var end := sprite.position + Vector2(offset, 0.0) _tween.interpolate_property( sprite, "position", start, end, 0.5, Tween.TRANS_QUINT, Tween.EASE_OUT ) _tween.interpolate_property( sprite, "modulate", Color.white, COLOR_WHITE_TRANSPARENT, 0.25, Tween.TRANS_LINEAR, Tween.EASE_OUT, 0.25 ) _tween.start() _tween.seek(0.0)