Add animations to popups, points, songs, the winner and the tabs (#48)
Build / test (push) Successful in 1m39s
Build / build (push) Successful in 1m14s

Popups fade in while growing and close the reverse way, with the backdrop
fading along. The winner gets confetti, bouncing pictures and a score graph
that draws itself from the left. Filtered statistics cards and search rows
fade in, the tabs slide in from their side, and every button bounces on hover
and click (ButtonBounce, now added to all buttons).

In the player list the score pops on every point, the first point flashes the
row, and a player on match point glows with a "Match point!" tag. A song
starting fades in the title, flashes its row (kept across list rebuilds) and
pops the play button.

Common/anim.gd holds the helpers. A saved "Reduce animations" switch in
Settings turns all movement off; the tests use it. The flash colours are
theme colours (Palette first_point, match_point, flash) in every theme.

Closes #48

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-12 21:49:01 +02:00
1 parent ae231975bd
commit 0fc3be79e1
31 files changed
+1510 -639

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
class_name Anim
extends RefCounted
## Short animations made with tweens, used all over the game: popping, fading, flashing
## and pulsing.
##
## Every animation is short so a quiz never waits for one. With
## [member Settings.reduce_animations] on, nothing moves: each helper jumps straight to
## where the animation would end.
## How long quick feedback, such as a button press, takes.
const FAST: float = 0.12
## How long most animations take.
const NORMAL: float = 0.2
## How long flashes and bigger moments take.
const SLOW: float = 0.45
## Whether animations are on.
static func enabled() -> bool:
return !Settings.reduce_animations
## A new tween on [param node] for the animation called [param key], stopping an earlier
## one with the same key so they don't fight. Returns null when animations are off or the
## node is not in the tree.
static func tween(node: Node, key: StringName = &"anim") -> Tween:
var meta: StringName = StringName("_tween_" + key)
var old: Variant = node.get_meta(meta) if node.has_meta(meta) else null
if old is Tween && (old as Tween).is_valid():
(old as Tween).kill()
if !enabled() || !node.is_inside_tree():
return null
var new_tween: Tween = node.create_tween()
node.set_meta(meta, new_tween)
return new_tween
## Makes [param control] grow from its middle.
static func center_pivot(control: Control) -> void:
control.pivot_offset = control.size / 2
## Makes [param control] jump to [param peak] times its size and spring back.
static func pop(control: Control, peak: float = 1.3, duration: float = NORMAL) -> void:
var pop_tween: Tween = tween(control, &"pop")
control.scale = Vector2.ONE
if pop_tween == null:
return
center_pivot(control)
control.scale = Vector2.ONE * peak
pop_tween.tween_property(control, "scale", Vector2.ONE, duration) \
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
## Makes [param control] grow from [param from] times its size with a bounce.
static func bounce_in(control: Control, from: float = 0.4, duration: float = SLOW) -> void:
var bounce_tween: Tween = tween(control, &"pop")
control.scale = Vector2.ONE
if bounce_tween == null:
return
center_pivot(control)
control.scale = Vector2.ONE * from
bounce_tween.tween_property(control, "scale", Vector2.ONE, duration * 1.6) \
.set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
## Fades [param item] in from invisible.
static func fade_in(item: CanvasItem, duration: float = NORMAL) -> void:
var fade_tween: Tween = tween(item, &"fade")
item.modulate.a = 1.0
if fade_tween == null:
return
item.modulate.a = 0.0
fade_tween.tween_property(item, "modulate:a", 1.0, duration)
## Slides [param control] in from [param offset] away from where it is, fading in.
static func slide_in(control: Control, offset: Vector2, duration: float = NORMAL) -> void:
var slide_tween: Tween = tween(control, &"slide")
if control.has_meta(&"_slide_home"):
control.position = control.get_meta(&"_slide_home")
control.modulate.a = 1.0
if slide_tween == null:
return
var home: Vector2 = control.position
control.set_meta(&"_slide_home", home)
control.position = home + offset
control.modulate.a = 0.0
slide_tween.set_parallel()
slide_tween.tween_property(control, "position", home, duration) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
slide_tween.tween_property(control, "modulate:a", 1.0, duration)
slide_tween.chain().tween_callback(func() -> void: control.remove_meta(&"_slide_home"))
## Scales [param control] to [param target] times its size, e.g. a little bigger under
## the mouse.
static func scale_to(control: Control, target: float, duration: float = FAST) -> void:
var scale_tween: Tween = tween(control, &"scale")
if scale_tween == null:
control.scale = Vector2.ONE
return
center_pivot(control)
scale_tween.tween_property(control, "scale", Vector2.ONE * target, duration) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
## Lights up [param container] with [param colour] for a moment, e.g. a row that just
## changed. A coloured layer is laid over the container and fades away.
static func flash(container: Container, colour: Color, duration: float = SLOW) -> void:
if !enabled() || !container.is_inside_tree():
return
var layer: ColorRect = _layer(container, colour)
var flash_tween: Tween = layer.create_tween()
flash_tween.tween_property(layer, "color:a", 0.0, duration) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
flash_tween.tween_callback(layer.queue_free)
## Keeps [param container] glowing in [param colour], softly pulsing, until the returned
## layer is freed. With animations off the glow stays still.
static func pulse(container: Container, colour: Color) -> ColorRect:
var layer: ColorRect = _layer(container, colour)
layer.name = "Pulse"
if enabled():
var pulse_tween: Tween = layer.create_tween().set_loops()
pulse_tween.tween_property(layer, "color:a", colour.a * 0.3, 0.6) \
.set_trans(Tween.TRANS_SINE)
pulse_tween.tween_property(layer, "color:a", colour.a, 0.6) \
.set_trans(Tween.TRANS_SINE)
return layer
# A layer of [param colour] over the whole of [param container] that lets clicks through
static func _layer(container: Container, colour: Color) -> ColorRect:
var layer: ColorRect = ColorRect.new()
layer.color = colour
layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(layer)
return layer
+1
View File
@@ -0,0 +1 @@
uid://67is3ejxq0j8
+29 -1
View File
@@ -4,6 +4,7 @@ extends RefCounted
## shrinks while it is held down, and can pop when its icon changes.
##
## The button is scaled around its middle, so its place in a container does not change.
## With [member Settings.reduce_animations] on, buttons keep their size.
## How big a button is under the mouse.
const HOVER_SCALE: Vector2 = Vector2(1.08, 1.08)
@@ -18,10 +19,32 @@ const POP_DURATION: float = 0.18
const _TWEEN_META: StringName = &"button_bounce_tween"
const _PIVOT_META: StringName = &"button_bounce_pivot"
const _ADDED_META: StringName = &"button_bounce_added"
## Makes [param button] grow under the mouse and shrink while held down.
## Gives every button in [param tree] the bounce, also buttons added later, e.g. rows
## of the player list. Check buttons are left out: they span a whole settings row.
static func add_to_every_button(tree: SceneTree) -> void:
if !tree.node_added.is_connected(_on_node_added):
tree.node_added.connect(_on_node_added)
var nodes: Array[Node] = [tree.root]
while !nodes.is_empty():
var node: Node = nodes.pop_back()
_on_node_added(node)
nodes.append_array(node.get_children())
static func _on_node_added(node: Node) -> void:
if node is BaseButton && !(node is CheckButton) && !(node is CheckBox):
add_to(node)
## Makes [param button] grow under the mouse and shrink while held down. Adding it to
## the same button twice does nothing.
static func add_to(button: BaseButton) -> void:
if button.has_meta(_ADDED_META):
return
button.set_meta(_ADDED_META, true)
_keep_pivot_centred(button)
button.mouse_entered.connect(func() -> void:
if !button.disabled:
@@ -38,6 +61,8 @@ static func resting_scale(button: BaseButton) -> Vector2:
## Shrinks [param button] and lets it spring back, e.g. when its icon changes.
static func pop(button: BaseButton) -> void:
if !Anim.enabled():
return
_keep_pivot_centred(button)
button.scale = POP_SCALE
var tween: Tween = _new_tween(button)
@@ -46,6 +71,9 @@ static func pop(button: BaseButton) -> void:
static func _scale_to(button: BaseButton, target: Vector2) -> void:
if !Anim.enabled():
button.scale = Vector2.ONE
return
var tween: Tween = _new_tween(button)
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(button, ^"scale", target, DURATION)
+3 -1
View File
@@ -29,7 +29,7 @@ const SAVED_SETTINGS: Dictionary[String, Array] = {
&"inspiration_list_speed",
],
"window": [&"fullscreen"],
"app": [&"last_seen_version", &"theme_id"],
"app": [&"last_seen_version", &"theme_id", &"reduce_animations"],
}
## The looks the game can have, picked in Settings: an id that is saved, the name shown
## and the theme file. The first is the default. See themes/README.md.
@@ -96,6 +96,8 @@ var queue_ids: Dictionary[String, String] = {}
## The version the game last showed the About popup for. When it is not [member version],
## this version is new and the main window opens the About popup to show what is new.
var last_seen_version: String = ""
## Turns every animation off, so nothing moves. See [Anim].
var reduce_animations: bool = false
## The id of the theme in use, one of [constant THEMES].
var theme_id: String = THEMES[0].id
## The folder songs are played from instead of the server, picked in the settings, or null
+11
View File
@@ -5,6 +5,9 @@ extends Control
##
## Everything that is not part of a quiz lives here.
## How far a tab slides in from the side when it is chosen.
const TAB_SLIDE: float = 60.0
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
var popup_backdrop: PopupBackdrop
@@ -23,6 +26,8 @@ var popup_backdrop: PopupBackdrop
func _ready() -> void:
popup_backdrop = PopupBackdrop.add_to(self, _popups())
# Every button grows a little under the mouse and shrinks when clicked
ButtonBounce.add_to_every_button(get_tree())
if OS.has_feature("macos"):
GameLog.debug("is_mac")
quiz.search_pressed.connect(show_search)
@@ -114,8 +119,14 @@ func show_qr() -> void:
func show_tab(tab: StringName) -> void:
GameLog.debug("show_tab ", tab)
top_bar.select_tab(tab)
var was_statistics: bool = statistics_view.visible
statistics_view.visible = tab == &"statistics"
quiz.visible = !statistics_view.visible
# The tab that comes in slides from its side: statistics from the right
if statistics_view.visible && !was_statistics:
Anim.slide_in(statistics_view, Vector2(TAB_SLIDE, 0))
elif quiz.visible && was_statistics:
Anim.slide_in(quiz, Vector2(-TAB_SLIDE, 0))
if statistics_view.visible:
statistics_view.refresh()
+2
View File
@@ -218,6 +218,8 @@ func play_song_object(song_object_no: int) -> void:
audio_player.play()
song_finished = false
_show_playing(true)
# A new song makes the play button pop, also when it was already playing
ButtonBounce.pop(play_button)
stream = audio_player.stream
progress_slider.value = 0
progress_slider.max_value = round(stream.get_length())
+7
View File
@@ -164,6 +164,13 @@ layout_mode = 2
focus_mode = 0
text = "Fullscreen"
[node name="ReduceAnimationsButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Reduce animations"
[node name="CacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer" unique_id=658210492]
layout_mode = 2
+51 -4
View File
@@ -4,32 +4,79 @@ extends Control
##
## Clicking outside [member panel] closes it. Popups that need to prepare or clean up
## override [method open] or [method close] and call [code]super()[/code].
##
## The panel fades in while growing from [constant START_SCALE] of its size, and closing
## does the reverse; the popup is hidden once that has finished. See [Anim].
## Emitted when the popup opens, also when it is opened again while closing.
signal opening
## Emitted when the popup starts closing, before it is hidden, so the backdrop can fade
## out with it.
signal closing
## How big the panel starts when it opens, and ends when it closes.
const START_SCALE: float = 0.95
## The visible part of the popup. Clicks outside it close the popup.
@export var panel: Control
## True while the popup is fading out after [method close].
var is_closing: bool = false
## Shows the popup.
func open() -> void:
is_closing = false
visible = true
opening.emit()
var open_tween: Tween = Anim.tween(panel, &"popup")
panel.modulate.a = 1.0
panel.scale = Vector2.ONE
if open_tween == null:
return
Anim.center_pivot(panel)
panel.modulate.a = 0.0
panel.scale = Vector2.ONE * START_SCALE
open_tween.set_parallel()
open_tween.tween_property(panel, "modulate:a", 1.0, Anim.NORMAL)
open_tween.tween_property(panel, "scale", Vector2.ONE, Anim.NORMAL) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
## Hides the popup.
func close() -> void:
visible = false
if !visible || is_closing:
return
is_closing = true
closing.emit()
var close_tween: Tween = Anim.tween(panel, &"popup")
if close_tween == null:
_hide()
return
Anim.center_pivot(panel)
close_tween.set_parallel()
close_tween.tween_property(panel, "modulate:a", 0.0, Anim.FAST)
close_tween.tween_property(panel, "scale", Vector2.ONE * START_SCALE, Anim.FAST)
close_tween.chain().tween_callback(_hide)
## Opens the popup if it is closed and closes it if it is open.
func toggle() -> void:
if visible:
if visible && !is_closing:
close()
else:
open()
func _hide() -> void:
is_closing = false
visible = false
panel.modulate.a = 1.0
panel.scale = Vector2.ONE
func _input(event: InputEvent) -> void:
if !visible || !(event is InputEventMouseButton) || !event.pressed:
if !visible || is_closing || !(event is InputEventMouseButton) || !event.pressed:
return
var click: InputEventMouseButton = panel.make_input_local(event)
if !Rect2(Vector2.ZERO, panel.size).has_point(click.position):
+23
View File
@@ -23,6 +23,8 @@ static func add_to(parent: Control, popups: Array[BasePopup]) -> PopupBackdrop:
for popup: BasePopup in popups:
parent.move_child(popup, -1)
popup.visibility_changed.connect(backdrop._update)
popup.closing.connect(backdrop._on_popup_closing)
popup.opening.connect(backdrop._on_popup_opening)
backdrop._update()
return backdrop
@@ -34,4 +36,25 @@ func _notification(what: int) -> void:
func _update() -> void:
var was_visible: bool = visible
visible = _popups.any(func(popup: BasePopup) -> bool: return popup.visible)
if visible && !was_visible:
Anim.fade_in(self)
# A popup opened again while closing stops the backdrop fading out
func _on_popup_opening() -> void:
var fade_tween: Tween = Anim.tween(self, &"fade")
if fade_tween == null || modulate.a >= 1.0:
modulate.a = 1.0
return
fade_tween.tween_property(self, "modulate:a", 1.0, Anim.FAST)
# Fades out with the last open popup, which hides the backdrop when it has closed
func _on_popup_closing() -> void:
if _popups.any(func(popup: BasePopup) -> bool: return popup.visible && !popup.is_closing):
return
var fade_tween: Tween = Anim.tween(self, &"fade")
if fade_tween != null:
fade_tween.tween_property(self, "modulate:a", 0.0, Anim.FAST)
+43 -3
View File
@@ -15,6 +15,13 @@ const MARGIN_BOTTOM: float = 44.0
## About how many song numbers fit along the bottom.
const MAX_SONG_LABELS: int = 20
## How much of the lines is drawn, from 0 (none) to 1 (all), so they can be drawn from
## left to right.
var progress: float = 1.0:
set(value):
progress = value
queue_redraw()
var _history: ScoreHistory
var _winning_score: int = 0
@@ -33,6 +40,32 @@ func show_history(history: ScoreHistory, winning_score: int) -> void:
queue_redraw()
## Draws the lines from left to right over [param duration] seconds.
func draw_lines_in(duration: float = 1.2) -> void:
var draw_tween: Tween = Anim.tween(self, &"draw")
progress = 1.0
if draw_tween == null:
return
progress = 0.0
draw_tween.tween_property(self, "progress", 1.0, duration) \
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
## The part of [param points] left of [param limit_x], cut where the line crosses it.
static func clip_line(points: PackedVector2Array, limit_x: float) -> PackedVector2Array:
var clipped: PackedVector2Array = []
for i: int in points.size():
if points[i].x <= limit_x:
clipped.append(points[i])
continue
if i > 0 && points[i - 1].x < limit_x:
var before: Vector2 = points[i - 1]
var weight: float = (limit_x - before.x) / (points[i].x - before.x)
clipped.append(before.lerp(points[i], weight))
break
return clipped
## The colour of the player at [param index] in the player list.
func player_color(index: int) -> Color:
return get_theme_color(StringName("player_%d" % (index % 7 + 1)), THEME_TYPE)
@@ -63,13 +96,20 @@ func _draw() -> void:
var line_width: int = get_theme_constant(&"line_width", THEME_TYPE)
var dot_radius: int = get_theme_constant(&"dot_radius", THEME_TYPE)
var limit_x: float = lerpf(MARGIN_LEFT, size.x - MARGIN_RIGHT, progress)
for index: int in _history.players.size():
var color: Color = player_color(index)
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
draw_polyline(line_points(index), color, line_width, true)
var points: PackedVector2Array = clip_line(line_points(index), limit_x)
if points.size() >= 2:
draw_polyline(points, color, line_width, true)
for song: int in range(1, scores.size()):
if scores[song] > scores[song - 1]:
draw_circle(_point(song, scores[song], index), dot_radius, color)
var dot: Vector2 = _point(song, scores[song], index)
if scores[song] > scores[song - 1] && dot.x <= limit_x:
draw_circle(dot, dot_radius, color)
# The final scores are written once the lines have reached them
if progress < 1.0:
return
var label_positions: Array[Vector2] = score_label_positions()
for index: int in _history.players.size():
draw_string(font, label_positions[index],
+3
View File
@@ -54,6 +54,9 @@ func search() -> void:
var row: PanelContainer = _rows[shown[position]]
if row.get_index() != position:
search_results.move_child(row, position)
# Rows that come back with the search fade in
if !row.visible:
Anim.fade_in(row, Anim.FAST)
row.visible = true
row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven"
_show_name(row, soundtracks[shown[position]], search_text)
+10 -1
View File
@@ -1,7 +1,7 @@
class_name SettingsPopup
extends BasePopup
## The application settings: which MusicServer or music folder to play from, the theme,
## fullscreen, how many songs to download ahead, whether to prefer less played soundtracks
## fullscreen, fewer animations, how many songs to download ahead, whether to prefer less played soundtracks
## and whether plays count in the statistics, and a button that opens the folder the
## settings are kept in. Opened from the Settings button in the top bar.
##
@@ -25,6 +25,7 @@ const PADDING: Vector2 = Vector2(20, 12)
@onready var settings_panel: Panel = %SettingsPanel
@onready var select_server_button: OptionButton = %SelectServerButton
@onready var fullscreen_button: CheckButton = %FullscreenButton
@onready var reduce_animations_button: CheckButton = %ReduceAnimationsButton
@onready var theme_button: OptionButton = %ThemeButton
@onready var cache_label: Label = %CacheLabel
@onready var lower_cache_button: Button = %LowerCacheButton
@@ -66,6 +67,8 @@ func _ready() -> void:
cache_label.text = str(Playlist.number_of_tracks_to_preload)
fullscreen_button.pressed.connect(fullscreen)
reduce_animations_button.button_pressed = Settings.reduce_animations
reduce_animations_button.toggled.connect(set_reduce_animations)
choose_folder_button.pressed.connect(folder_dialog.popup_centered)
folder_dialog.dir_selected.connect(use_music_folder)
use_server_button.pressed.connect(use_server)
@@ -93,6 +96,12 @@ func select_theme(index: int) -> void:
Settings.apply_theme(Settings.THEMES[index].id)
## Turns every animation off or on, and saves it.
func set_reduce_animations(on: bool) -> void:
Settings.reduce_animations = on
Settings.save_settings()
func fullscreen() -> void:
Settings.set_fullscreen(!Settings.fullscreen)
+1 -1
View File
@@ -56,7 +56,7 @@ func show_section_if_has_data(data: Variant, vbox: VBoxContainer, separator: VSe
func start_sync() -> void:
GameLog.debug("start_sync")
visible = true
open()
if has_synced:
# Already synced, just show the results
show_sync_result()
+52 -1
View File
@@ -2,7 +2,12 @@ class_name WinnerPopup
extends BasePopup
## Shows who won, with their character, where the other players placed, and how the game
## went: a graph of everyone's score after each song and a few facts about it.
##
## The winners' pictures bounce in, confetti falls over the panel and the graph draws its
## lines from left to right. See [Anim].
## How many pieces of confetti fly when someone wins.
const CONFETTI_AMOUNT: int = 160
## Size of the coloured square in front of each name in the legend.
const LEGEND_SQUARE_SIZE: Vector2 = Vector2(14, 14)
@@ -29,7 +34,7 @@ func show_winner(winner: PlayerObject) -> void:
## Shows [param winners], who share the top score, with everyone else's placing below.
## Tied players get the same placing. With no players, it says the quiz is over.
func show_winners(winners: Array[PlayerObject]) -> void:
visible = true
open()
winner_label.text = winners_text(winners)
_show_pictures(winners)
@@ -49,6 +54,7 @@ func show_winners(winners: Array[PlayerObject]) -> void:
players_list.add_child(label)
show_history(ScoreHistory.from_playlist(Settings.player_array))
_celebrate()
## The headline for [param winners], e.g. "Crono won with 5 points!!" or "Crono and
@@ -80,6 +86,51 @@ static func placing(player: PlayerObject) -> int:
return place
# The winners' pictures bounce in, confetti flies and the graph draws itself
func _celebrate() -> void:
for picture: Node in winner_picture.get_parent().get_children():
if picture is TextureRect && (picture as TextureRect).visible:
Anim.bounce_in(picture)
score_graph.draw_lines_in()
if Anim.enabled():
_throw_confetti()
# A burst of confetti from the top of the panel, in the players' graph colours
func _throw_confetti() -> void:
var old: Node = winner_panel.get_node_or_null("Confetti")
if old != null:
old.free()
var confetti: CPUParticles2D = CPUParticles2D.new()
confetti.name = "Confetti"
confetti.position = Vector2(winner_panel.size.x / 2, 0)
confetti.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
confetti.emission_rect_extents = Vector2(winner_panel.size.x / 2, 4)
confetti.amount = CONFETTI_AMOUNT
confetti.lifetime = 2.6
confetti.one_shot = true
confetti.explosiveness = 0.85
confetti.direction = Vector2.DOWN
confetti.spread = 35.0
confetti.gravity = Vector2(0, 260)
confetti.initial_velocity_min = 80.0
confetti.initial_velocity_max = 260.0
confetti.angular_velocity_min = -360.0
confetti.angular_velocity_max = 360.0
confetti.scale_amount_min = 5.0
confetti.scale_amount_max = 9.0
var colours: Gradient = Gradient.new()
colours.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_CONSTANT
colours.offsets = PackedFloat32Array()
colours.colors = PackedColorArray()
for i: int in 7:
colours.add_point(i / 7.0, score_graph.player_color(i))
confetti.color_initial_ramp = colours
confetti.finished.connect(confetti.queue_free)
winner_panel.add_child(confetti)
confetti.emitting = true
# One picture per winner, next to each other
func _show_pictures(winners: Array[PlayerObject]) -> void:
var header: Node = winner_picture.get_parent()
+8
View File
@@ -41,6 +41,14 @@ vertical_alignment = 1
clip_text = true
text_overrun_behavior = 3
[node name="MatchPointTag" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Match point!"
vertical_alignment = 1
[node name="Score" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 0)
+19 -1
View File
@@ -1,12 +1,18 @@
class_name MusicListPanel
extends PanelContainer
## The list of songs in the playlist, one row per song.
## The list of songs in the playlist, one row per song. A song's row flashes when the
## song starts playing and when it is answered.
## Emitted when the row of song number [param song_no] is clicked.
signal song_clicked(song_no: int)
var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
# Whether each song was playing and answered when the list was last shown, so a row
# can flash when its song starts or is answered
var _last_state: Dictionary = {}
# When each flash ends, in msec, so a flash goes on when the list is shown again
var _flash_ends: Dictionary = {}
@onready var _rows: VBoxContainer = %MusicList
@@ -15,6 +21,7 @@ var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
func refresh() -> void:
Settings.delete_children(_rows)
var songs: Array[SongObject] = Playlist.get_playlist()
var state: Dictionary = {}
for i: int in songs.size():
var s: SongObject = songs[i]
var new_song_list_object: SongRow = _song_list_object.instantiate()
@@ -22,6 +29,17 @@ func refresh() -> void:
new_song_list_object.index = i
new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number))
_rows.add_child(new_song_list_object)
state[s] = [s.is_playing, s.is_answered]
# Only songs that were listed before flash, not a whole list loaded at once
if _last_state.has(s) && _last_state[s] != state[s] \
&& (s.is_playing || s.is_answered):
_flash_ends[s] = Time.get_ticks_msec() + int(Anim.SLOW * 1000)
var left: float = (_flash_ends.get(s, 0) - Time.get_ticks_msec()) / 1000.0
if left > 0:
Anim.flash(new_song_list_object, get_theme_color(&"flash", &"Palette"), left)
else:
_flash_ends.erase(s)
_last_state = state
func _on_row_input(event: InputEvent, song_no: int) -> void:
+7 -1
View File
@@ -1,10 +1,16 @@
class_name NowPlaying
extends VBoxContainer
## The soundtrack and song that are playing, in large text at the top of the quiz.
## The soundtrack and song that are playing, in large text at the top of the quiz. They
## fade in when they change.
## Shows [param soundtrack_title] and [param song_title]. The playlist hides them as
## question marks until the answer is shown.
func show_song(soundtrack_title: String, song_title: String) -> void:
var changed: bool = $SoundtrackLabel.text != soundtrack_title \
|| $SongLabel.text != song_title
$SoundtrackLabel.text = soundtrack_title
$SongLabel.text = song_title
# A new song, or the answer being shown, fades in
if changed:
Anim.fade_in(self, Anim.SLOW)
+37
View File
@@ -2,6 +2,9 @@ class_name PlayerRow
extends PanelContainer
## One player in the player list: their name, points and character, and buttons for
## giving and taking points. Clicking the character picks another one.
##
## The score pops when it changes. The first point of a game makes the row flash, and a
## player one point from winning glows and says "Match point!" until that changes.
signal change_character_clicked
signal player_removed
@@ -15,6 +18,7 @@ var list_index: int
@onready var player_name_field: Label = %Name
@onready var score_label: Label = %Score
@onready var match_point_tag: Label = %MatchPointTag
@onready var character_frame: PanelContainer = %CharacterFrame
@onready var add_button: Button = %AddPoint
@onready var minus_button: Button = %RemovePoint
@@ -29,6 +33,9 @@ func _ready() -> void:
minus_button.set_shortcut(create_button_shortcut(list_index, true))
character.pressed.connect(change_character)
remove_player.pressed.connect(func() -> void: player_removed.emit())
player_object.player_point_given.connect(_on_score_changed)
player_object.player_point_taken.connect(_on_score_changed)
player_object.first_point_triggered.connect(_on_first_point)
character.mouse_entered.connect(_set_character_hovered.bind(true))
character.mouse_exited.connect(_set_character_hovered.bind(false))
# Rows take turns being lighter and darker, like the other lists
@@ -81,6 +88,36 @@ func set_player_character() -> void:
func update_score() -> void:
score_label.text = str(player_object.player_score)
_show_match_point(is_match_point())
## Whether the player is one point from winning. A prepared quiz has no winning score.
func is_match_point() -> bool:
return !Settings.is_prepared_quiz() && Settings.winning_score > 1 \
&& player_object.player_score == Settings.winning_score - 1
func _on_score_changed() -> void:
Anim.pop(score_label)
func _on_first_point() -> void:
Anim.flash(self, get_theme_color(&"first_point", &"Palette"), Anim.SLOW * 2)
Anim.pop(score_label, 1.8, Anim.SLOW)
# The glow and the tag show while the player is on match point
func _show_match_point(on: bool) -> void:
var glow: Node = get_node_or_null("Pulse")
if on == match_point_tag.visible && on == (glow != null):
return
match_point_tag.visible = on
if glow != null:
glow.queue_free()
glow.name = "PulseEnding"
if on:
Anim.pulse(self, get_theme_color(&"match_point", &"Palette"))
Anim.pop(match_point_tag, 1.4, Anim.SLOW)
func _set_character_hovered(hovered: bool) -> void:
+4
View File
@@ -124,10 +124,14 @@ func apply_filter() -> void:
var filter: ShowFilter = show_option.selected as ShowFilter
var shown: int = 0
for card: SoundtrackCard in cards:
var was_visible: bool = card.visible
card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \
&& _passes_filter(card.soundtrack, filter)
if card.visible:
shown += 1
# Cards that come back with the filter fade in
if !was_visible:
Anim.fade_in(card)
if shown == cards.size():
count_label.text = "%d soundtracks" % cards.size()
else:
File diff suppressed because it is too large. Load diff
+2
View File
@@ -30,6 +30,8 @@ func before_test() -> void:
Settings.winning_score = 20
# Otherwise the About popup opens at start, as for a new version
Settings.last_seen_version = Settings.version
# Popups open and close at once, so the tests don't wait for animations
Settings.reduce_animations = true
_server = FakeHttpTransport.new().install()
var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
+2
View File
@@ -175,6 +175,8 @@ static func prepare_autoloads() -> void:
Settings.use_low_played_mode = false
Settings.winning_score = 20
Settings.last_seen_version = Settings.version
# The same picture every time, without half-finished animations
Settings.reduce_animations = true
# The inspiration list scrolls by itself, which would make every picture different
Settings.inspiration_list_speed = 0
+215
View File
@@ -0,0 +1,215 @@
extends MainWindowTestSuite
## The animations, with Reduce animations off: popups, points, songs starting, the
## winner, filtering, the tabs and the buttons. The other tests run with animations off.
var _settings: SettingsPopup
func before_test() -> void:
await super()
Settings.reduce_animations = false
_settings = _window.get_node("SettingsPopup")
func _wait(seconds: float) -> void:
await await_millis(int(seconds * 1000) + 60)
func _layers(container: Node) -> Array[Node]:
return container.get_children().filter(func(node: Node) -> bool: return node is ColorRect)
func test_popup_fades_in_while_growing() -> void:
_settings.open()
assert_float(_settings.settings_panel.modulate.a).is_less(1.0)
assert_float(_settings.settings_panel.scale.x).is_less(1.0)
await _wait(Anim.NORMAL)
assert_float(_settings.settings_panel.modulate.a).is_equal(1.0)
assert_vector(_settings.settings_panel.scale).is_equal(Vector2.ONE)
func test_closing_popup_hides_after_fading_out() -> void:
_settings.open()
await _wait(Anim.NORMAL)
_settings.close()
assert_bool(_settings.visible).is_true()
assert_bool(_settings.is_closing).is_true()
await _wait(Anim.FAST)
assert_bool(_settings.visible).is_false()
assert_bool((_window.get_node("PopupBackdrop") as Control).visible).is_false()
# Ready to open again at full size
assert_float(_settings.settings_panel.modulate.a).is_equal(1.0)
func test_popup_opened_again_while_closing_stays_open() -> void:
_settings.open()
await _wait(Anim.NORMAL)
_settings.close()
_settings.open()
await _wait(Anim.NORMAL)
assert_bool(_settings.visible).is_true()
var backdrop: Control = _window.get_node("PopupBackdrop")
assert_bool(backdrop.visible).is_true()
assert_float(backdrop.modulate.a).is_equal(1.0)
func test_reduce_animations_opens_and_closes_at_once() -> void:
Settings.reduce_animations = true
_settings.open()
assert_float(_settings.settings_panel.modulate.a).is_equal(1.0)
_settings.close()
assert_bool(_settings.visible).is_false()
func test_reduce_animations_switch_is_saved() -> void:
# The switch shows the setting the window started with, which the tests turn on
assert_bool(_settings.reduce_animations_button.button_pressed).is_true()
_settings.reduce_animations_button.button_pressed = false
assert_bool(Settings.reduce_animations).is_false()
_settings.reduce_animations_button.button_pressed = true
assert_bool(Settings.reduce_animations).is_true()
var saved: ConfigFile = ConfigFile.new()
assert_int(saved.load(Settings.saved_settings_path())).is_equal(OK)
assert_bool(saved.get_value("app", "reduce_animations")).is_true()
func test_first_point_flashes_the_row_and_pops_the_score() -> void:
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
var row: PlayerRow = _player_row(crono)
row.add_point()
assert_array(_layers(row)).has_size(1)
assert_float(row.score_label.scale.x).is_greater(1.0)
await _wait(Anim.SLOW * 2)
assert_array(_layers(row)).is_empty()
assert_vector(row.score_label.scale).is_equal(Vector2.ONE)
func test_later_points_only_pop_the_score() -> void:
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
var row: PlayerRow = _player_row(crono)
row.add_point()
await _wait(Anim.SLOW * 2)
row.add_point()
assert_array(_layers(row)).is_empty()
assert_float(row.score_label.scale.x).is_greater(1.0)
func test_match_point_glows_and_says_so_until_it_changes() -> void:
Settings.winning_score = 3
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
var row: PlayerRow = _player_row(crono)
row.add_point()
row.add_point()
assert_bool(row.match_point_tag.visible).is_true()
assert_object(row.get_node_or_null("Pulse")).is_not_null()
row.minus_point()
assert_bool(row.match_point_tag.visible).is_false()
assert_object(row.get_node_or_null("Pulse")).is_null()
func test_match_point_glows_still_with_reduced_animations() -> void:
Settings.reduce_animations = true
Settings.winning_score = 2
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
_player_row(crono).add_point()
assert_object(_player_row(crono).get_node_or_null("Pulse")).is_not_null()
func test_song_start_fades_in_the_title_and_flashes_its_row() -> void:
Settings.hide_next_track = false
# The next song is downloaded while the first plays
_next_song_is({"SongNo": 1, "Soundtrack": "Final Fantasy VI", "Song": "Dancing Mad"})
await _play_first_song()
await _wait(Anim.SLOW)
_press("SongControls/NextButton")
await _wait(0.55)
var now_playing: Control = _quiz.get_node("NowPlaying")
assert_float(now_playing.modulate.a).is_less(1.0)
var rows: Array[Node] = _quiz.music_list_panel.get_node("%MusicList").get_children()
var flashing: Array = rows.filter(func(row: Node) -> bool: return !_layers(row).is_empty())
assert_array(flashing).is_not_empty()
func test_winner_gets_confetti_and_a_graph_drawn_from_the_left() -> void:
Settings.winning_score = 1
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
_player_row(crono).add_point()
await _runner.simulate_frames(1)
var winner: WinnerPopup = _quiz.winner_popup
assert_bool(winner.visible).is_true()
assert_object(winner.winner_panel.get_node_or_null("Confetti")).is_not_null()
assert_float(winner.score_graph.progress).is_less(1.0)
await _wait(1.3)
assert_float(winner.score_graph.progress).is_equal(1.0)
func test_graph_lines_are_cut_where_the_drawing_has_reached() -> void:
var points: PackedVector2Array = [Vector2(0, 0), Vector2(10, 0), Vector2(10, 10),
Vector2(30, 10)]
assert_array(Array(ScoreGraph.clip_line(points, 20))) \
.contains_exactly([Vector2(0, 0), Vector2(10, 0), Vector2(10, 10), Vector2(20, 10)])
assert_array(Array(ScoreGraph.clip_line(points, 100))).has_size(4)
assert_array(Array(ScoreGraph.clip_line(points, 5))) \
.contains_exactly([Vector2(0, 0), Vector2(5, 0)])
func test_statistics_tab_slides_in_and_ends_in_place() -> void:
var statistics: Control = _window.get_node("StatisticsView")
var home: Vector2 = statistics.position
_window.show_tab(&"statistics")
assert_float(statistics.position.x).is_greater(home.x)
await _wait(Anim.NORMAL)
assert_vector(statistics.position).is_equal(home)
assert_float(statistics.modulate.a).is_equal(1.0)
func test_every_button_bounces() -> void:
var sync: Button = _window.get_node("TopBar/%SyncButton")
var reset: Button = _quiz.get_node("QuizToolbar/ResetPointsButton")
var crono: PlayerObject = _add_player("Crono")
for button: BaseButton in [sync, reset, _player_row(crono).add_button]:
assert_bool(button.has_meta(&"button_bounce_added")).override_failure_message(
button.name).is_true()
func test_helpers_do_nothing_with_reduced_animations() -> void:
Settings.reduce_animations = true
var label: Label = _quiz.get_node("NowPlaying/SongLabel")
Anim.pop(label)
Anim.fade_in(label)
assert_vector(label.scale).is_equal(Vector2.ONE)
assert_float(label.modulate.a).is_equal(1.0)
+1
View File
@@ -0,0 +1 @@
uid://cuukc27kr8360
+1 -1
View File
@@ -64,7 +64,7 @@ const STYLES: Dictionary = {
## Theme types that only scripts read from, so they are not styles a node can pick.
const CODE_ONLY_TYPES: Dictionary = {
"Palette": ["backdrop"],
"Palette": ["backdrop", "first_point", "match_point", "flash"],
"ScoreGraph": [
"player_1", "player_2", "player_3", "player_4", "player_5", "player_6", "player_7",
"grid", "axis_text", "winning_line",
+1
View File
@@ -7,6 +7,7 @@ const SAVED_VALUES: Dictionary = {
&"hide_beginning": true, &"hide_for_seconds": 7.0, &"hide_length": true,
&"hide_ticks": true, &"winning_score": 13, &"inspiration_list_speed": 4,
&"fullscreen": true, &"last_seen_version": "1.2.3", &"theme_id": "original_web",
&"reduce_animations": true,
}
var _snapshot: AutoloadSnapshot
+3
View File
@@ -88,6 +88,9 @@ inspector or `theme_type_variation` in code:
| Colour | Used for |
|---|---|
| `backdrop` | The dimming behind an open popup |
| `first_point` | The flash over a player's row on their first point (see `common/anim.gd`) |
| `match_point` | The glow over a player's row while they are one point from winning |
| `flash` | The flash over a song's row when it starts playing or is answered |
**ScoreGraph**: the graph of the game on the Winner popup, drawn from code.
+3
View File
@@ -1356,6 +1356,9 @@ OptionButton/styles/normal = SubResource("StyleBoxFlat_a4gt2")
OptionButton/styles/normal_mirrored = SubResource("StyleBoxFlat_uisqs")
OptionButton/styles/pressed = SubResource("StyleBoxFlat_dinf2")
OptionButton/styles/pressed_mirrored = SubResource("StyleBoxFlat_a0ksy")
Palette/colors/first_point = Color(0.498, 0.82, 0.545, 0.4)
Palette/colors/match_point = Color(0.949, 0.843, 0.478, 0.35)
Palette/colors/flash = Color(0.992, 0.98, 0.953, 0.22)
Palette/colors/backdrop = Color(0.1, 0.12, 0.16, 0.55)
Panel/styles/panel = SubResource("StyleBoxFlat_hwwyi")
PanelContainer/styles/panel = SubResource("StyleBoxFlat_wl3gy")
+3
View File
@@ -1164,6 +1164,9 @@ OptionButton/styles/normal = SubResource("StyleBoxFlat_a4gt2")
OptionButton/styles/normal_mirrored = SubResource("StyleBoxFlat_uisqs")
OptionButton/styles/pressed = SubResource("StyleBoxFlat_dinf2")
OptionButton/styles/pressed_mirrored = SubResource("StyleBoxFlat_a0ksy")
Palette/colors/first_point = Color(0.3, 0.85, 0.4, 0.35)
Palette/colors/match_point = Color(1, 0.5, 0, 0.3)
Palette/colors/flash = Color(1, 1, 1, 0.22)
Palette/colors/backdrop = Color(0, 0, 0, 0.55)
Panel/styles/panel = SubResource("StyleBoxFlat_hwwyi")
PanelContainer/styles/panel = SubResource("StyleBoxFlat_wl3gy")
+3
View File
@@ -1339,6 +1339,9 @@ OptionButton/styles/normal = SubResource("StyleBoxFlat_a4gt2")
OptionButton/styles/normal_mirrored = SubResource("StyleBoxFlat_uisqs")
OptionButton/styles/pressed = SubResource("StyleBoxFlat_dinf2")
OptionButton/styles/pressed_mirrored = SubResource("StyleBoxFlat_a0ksy")
Palette/colors/first_point = Color(0.639, 0.745, 0.549, 0.4)
Palette/colors/match_point = Color(0.922, 0.796, 0.545, 0.3)
Palette/colors/flash = Color(0.925, 0.937, 0.957, 0.18)
Palette/colors/backdrop = Color(0.1, 0.12, 0.16, 0.55)
Panel/styles/panel = SubResource("StyleBoxFlat_hwwyi")
PanelContainer/styles/panel = SubResource("StyleBoxFlat_wl3gy")
+3
View File
@@ -1265,6 +1265,9 @@ OptionButton/styles/normal = SubResource("StyleBoxFlat_a4gt2")
OptionButton/styles/normal_mirrored = SubResource("StyleBoxFlat_uisqs")
OptionButton/styles/pressed = SubResource("StyleBoxFlat_dinf2")
OptionButton/styles/pressed_mirrored = SubResource("StyleBoxFlat_a0ksy")
Palette/colors/first_point = Color(0.298, 0.686, 0.314, 0.4)
Palette/colors/match_point = Color(1, 0.612, 0, 0.3)
Palette/colors/flash = Color(1, 1, 0, 0.18)
Palette/colors/backdrop = Color(0, 0, 0, 0.6)
Panel/styles/panel = SubResource("StyleBoxFlat_hwwyi")
PanelContainer/styles/panel = SubResource("StyleBoxFlat_wl3gy")