In this short lesson, we will improve the menu’s visuals by tweaking the individual tiles.
I split it into a different lesson to take a few moments to talk about performance optimization.
First, though, let’s update our tracks’ visuals.
We create a new function that updates the tiles transforms in a loop and call it whenever we should update them.
Open the TrackTiles.gd
script and let’s first define the _update_tile_visuals()
function.
Below, we are trying to calculate a value we can use for each tile’s scale and the transparency, a number between zero and one.
To do so, we use the range_lerp()
function, which maps a value from a given range to a value between zero and one.
func _update_tile_visuals() -> void: var carousel_position_x: float = get_parent().global_position.x for track_tile in _track_tiles: # For each tile, we start by calculating the horizontal distance to the # `TrackCarousel` node. var distance_to_view_center := abs(track_tile.global_position.x - carousel_position_x) # This calculation gives us a value between zero and one. # If the tile is centred on the track carousel node, the value will be # zero. # If it is on either on the edge of the screen or outside the screen, # the value will be one. var distance_normalized = range_lerp( distance_to_view_center, 0.0, carousel_position_x, 0.0, 1.0 ) # We can then take one minus `distance_normalized` value to calculate # our scale. track_tile.scale = Vector2.ONE * (1.0 - distance_normalized) # We do something similar for the opacity, but introduce a power # function to only fade the tiles on the outer edges on the screen. track_tile.modulate.a = (1.0 - pow(distance_normalized, 3.0))
We then have to call this function in three places:
func _ready() -> void: #... _update_tile_visuals() func _process(_delta: float) -> void: if _tween.is_active(): _update_tile_visuals() func scroll(amount: Vector2) -> void: #... _update_tile_visuals()
And with that, you can enjoy a much nicer-looking menu already, which is now done.
Let’s now take a moment to discuss performance.
The way we implemented the tiles’ update, we always process all TrackTile
instances. When the player clicks and drags the tile, we loop through them all and update their transform.
If you have 100
tracks, the script will process all 100
of them as the player clicks and drags.
With three, it’s fine, but this code wouldn’t scale well to processing, say, a thousand. Not even a hundred on low-end mobile devices. The framerate would likely start to drop.
To release the game on low-end mobile platforms, you would likely need to optimize that code to provide a smooth experience.
Or would you?
This horizontal tracklist works well provided you only have a few dozen tiles at most, in which case the framerate itself may not be an issue.
With hundreds of soundtracks, the interface won’t work either. It would take ages for the player to flick through them all. I designed the current interface to support only several dozens of tracks.
Thus, if you wanted to expand the library past that, you’d have to rethink the UX.
You could nest the tiles in expendable categories, as seen in community-driven rhythm games like Osu! and Stepmania, addressing both UX and performance problems.
However, the game’s smoothness is not the only reason to optimize your applications.
On mobile devices, you also have to take battery usage into account. The more performance-intensive your code is, the faster it will drain the player’s battery.
And that, on any handheld device, be it a laptop, a tablet, a mobile, or the portable version of the Nintendo Switch.
Have you noticed how some games will eat your phone’s battery within one hour? It’s either they’re pushing your phone to the limit, or sometimes they’re just poorly optimized.
Here, we can introduce a small optimization that does not make our code too complex yet improves the scroll performance by about 500%.
The best optimization you can make is to skip processing what’s outside the screen. It generally requires a little extra code for great benefits.
While I don’t recommend optimizing prematurely, whenever you have big loops, the continue
and break
keywords can help you save a lot of processing time.
Here, all we have to do is update the loop in _update_tile_visuals()
.
We check if the tile is in the view. If not, we skip updating its visuals.
func _update_tile_visuals() -> void: #... # We first get the viewport's rectangle and expand it to give us a safe # margin. # Without expanding it, you'll get visual artifacts where tiles won't # properly update on the edges of the screen. var expanded_view_bounds := get_viewport_rect().grow(200.0) for track_tile in _track_tiles: # We then use `Rect2.has_point()` to check if the tile is in the view. # If not, we skip updating it! if not expanded_view_bounds.has_point(track_tile.global_position): continue #...
Besides skipping things outside the view, optimizing code can take time and make scripts more complex and harder to change.
In other words, optimization always comes at a cost, and so you want to be mindful of doing it when and where it’s really beneficial or necessary.
That’s also why you see some game studios delay work on that aspect towards the end of production.
Although, I don’t think skipping processing in big loops or returning early in functions is premature optimization. If you do it as you go, it won’t take you much time.
That’s a simple way to optimize.
Godot provides another built-in node that’ll help you greatly: VisibilityEnabler2D
. It can automatically toggle animation, processing, and more on nodes that go outside the view. It directly affects its parent or a sibling AnimationPlayer.
Its purpose is similar to the code above but in the form of a node. Pigdev made a video dedicated to it, which you can find on our website: VisibilityNotifier2D and VisibilityEnabler2D in Godot.
Past that, to optimize your code, you always want to measure performance.
Godot comes with a profiler, a tool developers use to do just that. It will give you insights into your code’s performance and help you decide what is worth improving.
If you want to learn more, we wrote a free guide on that topic: how to measure code performance in Godot.
Okay, so we are done with our track selection menu and getting close to the project’s end.
In the next lesson, we will add a simple end screen for when the player finishes a track. This will allow them to go back to the track selection menu.
And to wrap up the project, we will add the rollers, another gameplay element consisting of 2 buttons connected by a path you must follow with the finger or the mouse.
Here’s the complete TrackTiles.gd
script.
extends Node2D export (Array, Resource) var tracks: Array export var track_tile_scene: PackedScene export var separation := 450 var _track_tiles := [] var _min_x_position := 0.0 var _selected_track_tile: TrackTile onready var _align_timer := $AlignTimer onready var _tween := $Tween func _ready() -> void: var res = tracks[0] tracks.clear() for i in 5000: tracks.append(res) _generate_tiles() _update_tile_visuals() func _unhandled_input(event: InputEvent) -> void: if event.is_action_released("touch"): _align_timer.start() func _process(delta: float) -> void: if _tween.is_active(): _update_tile_visuals() func scroll(amount: Vector2) -> void: position.x = clamp(position.x + amount.x, _min_x_position, 0) _tween.stop_all() _update_tile_visuals() func _generate_tiles() -> void: var offset := 0.0 for i in tracks.size(): var track_data: TrackData = tracks[i] var track_tile: TrackTile = track_tile_scene.instance() track_tile.track_data = track_data _track_tiles.append(track_tile) track_tile.position = Vector2(offset, 0) offset += separation add_child(track_tile) _min_x_position = -(separation * (_track_tiles.size() - 1)) func _snap_to_track(track_tile: TrackTile) -> void: var offset_to_center := Vector2(get_parent().global_position.x - track_tile.global_position.x, 0) _tween.interpolate_property( self, "position", position, position + offset_to_center, 0.5, Tween.TRANS_EXPO, Tween.EASE_OUT ) _tween.start() func _update_tile_visuals() -> void: var carousel_position_x: float = get_parent().global_position.x var expanded_view_bounds := get_viewport_rect().grow(200.0) for track_tile in _track_tiles: if not expanded_view_bounds.has_point(track_tile.global_position): continue var distance_to_view_center := abs(track_tile.global_position.x - carousel_position_x) var distance_normalized = range_lerp( distance_to_view_center, 0.0, carousel_position_x, 0.0, 1.0 ) track_tile.scale = Vector2.ONE * (1.0 - distance_normalized) track_tile.modulate.a = (1.0 - pow(distance_normalized, 3.0)) func _on_DragDetector_dragged(amount: Vector2) -> void: scroll(amount) func _on_SelectArea_track_selected(track_tile: TrackTile) -> void: _selected_track_tile = track_tile func _on_AlignTimer_timeout() -> void: _snap_to_track(_selected_track_tile)