Files
MusicPlayer/popups/score_graph.gd
T
SansanandClaude Opus 5.5 0fc3be79e1
Build / test (push) Successful in 1m39s
Build / build (push) Successful in 1m14s
Add animations to popups, points, songs, the winner and the tabs (#48)
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>
2026-10-12 21:49:01 +02:00

207 lines
8.0 KiB
GDScript

class_name ScoreGraph
extends Control
## Draws every player's score after each song of the game as a step line, so streaks and
## stretches where nobody scored stand out. Shown on the Winner popup.
##
## Colours and sizes come from the theme's [code]ScoreGraph[/code] type. Each player gets
## the colour of their position in [code]Settings.player_array[/code].
const THEME_TYPE: StringName = &"ScoreGraph"
## Room for the score labels on the left and right, and the song labels below.
const MARGIN_LEFT: float = 32.0
const MARGIN_RIGHT: float = 32.0
const MARGIN_TOP: float = 22.0
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
func _notification(what: int) -> void:
# The colours come from the theme, which can be switched in Settings
if what == NOTIFICATION_THEME_CHANGED:
queue_redraw()
## Shows [param history]. The y axis goes up to [param winning_score], or higher if
## someone has more points. A winning score of 0 draws no winning line.
func show_history(history: ScoreHistory, winning_score: int) -> void:
_history = history
_winning_score = winning_score
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)
## Where the line of the player at [param index] is drawn after each song, starting
## before the first song.
func line_points(index: int) -> PackedVector2Array:
var points: PackedVector2Array = []
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
points.append(_point(0, 0, index))
for song: int in range(1, scores.size()):
if scores[song] != scores[song - 1]:
points.append(_point(song, scores[song - 1], index))
points.append(_point(song, scores[song], index))
points.append(_point(scores.size() - 1, scores.back(), index))
return points
func _draw() -> void:
if _history == null:
return
var font: Font = get_theme_default_font()
var font_size: int = get_theme_font_size(&"font_size", THEME_TYPE)
var text_color: Color = get_theme_color(&"axis_text", THEME_TYPE)
_draw_score_axis(font, font_size, text_color)
_draw_song_axis(font, font_size, text_color)
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)
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()):
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],
str(_history.final_score(_history.players[index].id)),
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, player_color(index))
## Where each player's score is written at the end of their line, as the baseline of the
## text. Scores that would overlap are moved apart.
func score_label_positions() -> Array[Vector2]:
var font_size: int = get_theme_font_size(&"font_size", THEME_TYPE)
var positions: Array[Vector2] = []
for index: int in _history.players.size():
var score: int = _history.final_score(_history.players[index].id)
var end: Vector2 = _point(_history.songs.size(), score, index)
positions.append(end + Vector2(8, font_size * 0.35))
# From the top down, push each label below the one above it
var order: Array = range(positions.size())
order.sort_custom(func(a: int, b: int) -> bool: return positions[a].y < positions[b].y)
for i: int in range(1, order.size()):
var above: Vector2 = positions[order[i - 1]]
if positions[order[i]].y - above.y < font_size:
positions[order[i]].y = above.y + font_size
return positions
## The songs whose numbers are written along the bottom.
func labelled_songs() -> Array[int]:
var song_count: int = _history.songs.size()
var step: int = maxi(1, ceili(song_count / float(MAX_SONG_LABELS)))
var songs: Array[int] = []
for song: int in range(1, song_count + 1):
# The last song is always labelled, unless that would crowd its neighbour
var is_last: bool = song == song_count && (song - 1) % step >= step / 2.0
if (song - 1) % step == 0 || is_last:
songs.append(song)
return songs
## Where on the x axis [param song] is, counting the start of the game as song 0.
func song_x(song: int) -> float:
return _x(song)
func _draw_score_axis(font: Font, font_size: int, text_color: Color) -> void:
var grid_color: Color = get_theme_color(&"grid", THEME_TYPE)
var top: int = _top_score()
var step: int = maxi(1, ceili(top / 10.0))
for score: int in range(0, top + 1, step):
var y: float = _y(score)
draw_line(Vector2(MARGIN_LEFT, y), Vector2(size.x - MARGIN_RIGHT, y), grid_color, 1)
draw_string(font, Vector2(0, y + font_size * 0.35), str(score),
HORIZONTAL_ALIGNMENT_RIGHT, MARGIN_LEFT - 8, font_size, text_color)
if _winning_score <= 0:
return
var winning_color: Color = get_theme_color(&"winning_line", THEME_TYPE)
var winning_y: float = _y(_winning_score)
draw_dashed_line(Vector2(MARGIN_LEFT, winning_y), Vector2(size.x - MARGIN_RIGHT, winning_y),
winning_color, 1, 6)
draw_string(font, Vector2(MARGIN_LEFT + 4, winning_y - 6), "winning score",
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, winning_color)
func _draw_song_axis(font: Font, font_size: int, text_color: Color) -> void:
var label_y: float = size.y - MARGIN_BOTTOM + font_size + 6
for song: int in labelled_songs():
draw_string(font, Vector2(_x(song) - 20, label_y), str(song),
HORIZONTAL_ALIGNMENT_CENTER, 40, font_size, text_color)
draw_string(font, Vector2(0, size.y - 4), "Song", HORIZONTAL_ALIGNMENT_CENTER, size.x,
font_size, text_color)
func _point(song: int, score: int, index: int) -> Vector2:
# Lines of players with the same score sit side by side instead of on top of each other
var spread: float = get_theme_constant(&"line_width", THEME_TYPE) \
* (index - (_history.players.size() - 1) / 2.0)
return Vector2(_x(song), _y(score) + spread)
func _x(song: int) -> float:
var width: float = size.x - MARGIN_LEFT - MARGIN_RIGHT
return MARGIN_LEFT + song * width / maxi(_history.songs.size(), 1)
func _y(score: int) -> float:
var height: float = size.y - MARGIN_TOP - MARGIN_BOTTOM
return size.y - MARGIN_BOTTOM - score * height / _top_score()
func _top_score() -> int:
var top: int = maxi(_winning_score, 1)
for player: PlayerObject in _history.players:
top = maxi(top, _history.final_score(player.id))
return top