13 Commits
Author SHA1 Message Date
SansanandClaude Opus 5.5 0862d160e5 Give the sync progress bar colour and animation
Build / test (push) Successful in 1m55s
Build / build (push) Successful in 1m12s
The bar in the Sync popup is now an AnimatedProgressBar: taller, with a
darker track and the theme's accent fill (orange, frost blue in Nord, green
with a gold shine for Christmas). It shows Godot's busy animation until the
server reports progress, glides to each new value, has a soft highlight
sweeping along the filled part inside its rounded corners, and fills and
pops before the result shows. With Reduce animations it only jumps to its
values.

The styles are SyncProgressBar, SyncProgressBarFill and SyncProgressBarText
in every theme. Anim.stop stops a keyed animation without starting an empty
one.

The server now sends sync times as "MM:SS"; the spec is updated from
MusicServer develop (also the linux-package route), and the test data and
screenshots use the new format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:13:43 +02:00
SansanandClaude Opus 5.5 f39865fffc Update the game from inside it (#13)
Build / test (push) Successful in 1m53s
Build / build (push) Successful in 1m15s
"Version X is available" in the top bar is now a button that opens an
Update popup:
- Update and restart gets the download link from the server (the redirect
  is not followed, as the Gitea link needs no token), downloads the new game
  next to the running one with a progress bar, checks it (complete, and an
  MZ or ELF file), swaps it in and restarts. The quiz is saved first and
  continues. On Windows the running .exe is renamed to .old, put back if
  the swap fails, and deleted at the next start.
- Download in browser opens the .exe, the Linux package, or the release
  page on other platforms.
- From the editor, an unwritable folder or another platform, only the
  browser download is offered, with the reason.

The message now fits between the tabs and the buttons in every theme; in
Nord and Christmas it ran into Sync. The macOS export preset is removed,
and the unused download_client_* functions are replaced by
fetch_client_download_url.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:23:26 +02:00
SansanandClaude Opus 5.5 c03c0e4efe Add Clear cache to the settings
Build / test (push) Successful in 1m51s
Build / build (push) Successful in 1m14s
A new row, "Downloaded pictures and songs", shows how much space the cached
character pictures, covers and downloaded songs take, with a Clear cache
button. Clearing deletes them, but keeps the songs the current quiz uses,
the settings, config.ini and the saved quiz. The characters are loaded
again at once; covers come back when they are shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:50:58 +02:00
SansanandClaude Opus 5.5 06421bb63e Change soundtrack covers from the app and show the cover with the answer
Build / test (push) Successful in 1m50s
Build / build (push) Successful in 1m14s
Cover menu in the soundtrack popup ("Change cover"):
- Choose picture uploads a JPEG, PNG or WebP picture (up to 10 MB).
- Pick by ID uses a game's cover on SteamGridDB or an anime's on AniList.
- Remove cover deletes it, so the server searches for a new one.
- The server's reason is shown when it refuses a change.
After a change the new cover time is fetched, and the popup, the
statistics card and the quiz show the new cover.

Quiz: once the answer is shown, the soundtrack's cover shows to the left of
the titles. Cover and titles are centered together, left and right and up
and down, and each title keeps to two lines. Cover times come from the
soundtrack list, and songs keep their soundtrack's UUID, also in saves.

Requests can now send any body with its content type, and every 2xx answer
counts as success. Cover frames are 2:3 like the server's covers. Small
copies of real covers are used by the tests and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:32:09 +02:00
SansanandClaude Opus 5.5 ef6ac8f1fd Show soundtrack covers and edit alternative names (#49)
Build / test (push) Successful in 1m43s
Build / build (push) Successful in 1m14s
Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.

Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.

The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.

Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:06:30 +02:00
SansanandClaude Opus 5.5 a735c515bf Add Short songs and a choice of how random songs are picked (#30, #51)
Build / test (push) Successful in 1m40s
Build / build (push) Successful in 1m28s
Short songs (#30): a quiz settings switch and a Seconds per song row (30 by
default, 10-120 in steps of 5), saved with the quiz settings. Only part of
each quiz song plays, from a random point 10-60% into it, picked once per song
so restart and repeat play the same part. The progress bar, time and seeking
stay within the part, a hidden beginning counts inside it, and its end counts
as the song ending (repeat, stop or go to the next song as set).

Random songs (#51): the low-played switch in Settings is now a Normal / Low
played / Classic choice, stored as Settings.random_mode and not saved.
fetch_random_song(mode) picks /music/rand, /music/rand/low or
/music/rand/classic.

Closes #30
Closes #51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:21:20 +02:00
SansanandClaude Opus 5.5 0fc3be79e1 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>
2026-10-12 21:49:01 +02:00
SansanandClaude Opus 5.5 ae231975bd Show each player's picture first in the player list
Build / test (push) Successful in 1m30s
Build / build (push) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:18:19 +02:00
SansanandClaude Opus 5.5 b9fa3c3d63 Add a Christmas theme
Christmas red window and panels with snow white buttons and red text, a thick
white "fur" trim on popups, cards, the lists and the player bar, the tree's
green for the chosen tab and the play button, and gold for the playing song and
highlights. Icons on buttons take the text's colour, and drop-down lists stay
dark red so their arrow shows. It uses Nord's round shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:18:19 +02:00
SansanandClaude Opus 5.5 de94691073 Add a Nord theme and give the player bar room around it (#43)
Build / test (push) Successful in 1m31s
Build / build (push) Successful in 1m14s
Nord (themes/nord_theme.tres) uses the app icon's blues, darkened, with Nord's
Snow Storm text, Frost and Aurora accents and the playing song in Aurora
yellow. Its shapes are round: pill buttons and tabs, 14-16 px corners, soft
shadows on popups, cards and the player bar, rounded scroll bars, sliders and
text fields, and seven Aurora/Frost colours for the score graph. Classic stays
the default.

The player bar now has 16 px to the song buttons, the player list, Quiz
settings and the window's bottom edge, and the right edges line up at 1856.

The volume icon is a flat button with a PlayerIcon style, so each theme colours
it; it was nearly invisible on Original web's light grey bar.

Closes #43

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:06:39 +02:00
SansanandClaude Opus 5.5 94e8dfb602 Add a theme picker and an Original web theme (#42)
Build / test (push) Successful in 1m31s
Build / build (push) Successful in 1m13s
Settings has a Theme row listing Settings.THEMES: Classic (main_theme.tres,
still the default) and Original web (original_web_theme.tres), which looks like
MusicFrontend: orange-bordered buttons, a #333 header and popups, #424242
panels, #4e4e4e alternating rows, a yellow playing song and a light grey
player bar. Picking one sets the root window's theme at once and saves it as
theme_id in settings.cfg, and it is applied at start.

The popup backdrop and score graph follow a theme switch. The top bar has its
own TopBarBackground style. test_theme.gd checks that every theme has
everything main_theme.tres has. The screenshot tool takes a theme id.

Closes #42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:50:12 +02:00
SansanandClaude Opus 5.5 70e970b24c Make the buttons consistent and rework the player list
Build / test (push) Successful in 1m30s
Build / build (push) Successful in 1m17s
Buttons get 12 px of side padding. The top bar buttons, the quiz toolbar and
Show answer/Next are laid out in rows with 12 px gaps and are all 36 px tall,
and the ±1 buttons use a compact StepButton style. "Randomize new track" is now
"Next song".

Player rows show the name and the score apart, alternate like the other lists
and highlight the character under the mouse. The name field is a LineEdit with
a 10-letter limit; blank and duplicate names are refused, and a full list says
"Up to 7 players". Remove buttons update when editing is switched instead of
every frame, and the spacer panels are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:35:24 +02:00
Sansan e4352a9f34 Save the quiz and continue it after a restart; load characters from a folder
Build / test (push) Successful in 1m25s
Build / build (push) Successful in 1m12s
The quiz is saved to current_quiz.json in the config folder after every
change, and the game goes on where it was when it starts again: the
players with their points and characters, every song with who got a point
for it and whether it was played, answered or is playing, and the songs
downloaded ahead with their files. The song that was playing is ready to
play again. Songs whose files are gone are downloaded again.

On a server, the saved songs are checked against the server's playlist. If
it changed, e.g. after a sync, or another server is used, the quiz starts
over with the same players. A quiz from a music folder continues offline,
including how far a prepared quiz has come. A new game is saved like any
other change, and a file that can't be read starts a new quiz.

Characters can be read from a folder picked in Settings instead of the
server, for this session. A music folder with a characters folder brings
its own characters, e.g. for a themed quiz; that folder is not counted as
a soundtrack. When the characters come from somewhere else, players pick
their characters again.

Closes #11, closes #44
2026-10-09 18:08:50 +02:00
147 changed files with 27162 additions and 5366 deletions

No files matched your search

+12
View File
@@ -27,3 +27,15 @@ Unpack `MusicPlayer_<version>_linux.tar.gz` and run `./install.sh` inside the
`MusicPlayer` folder. It installs the game to `~/.local/bin` and adds it, with its icon, `MusicPlayer` folder. It installs the game to `~/.local/bin` and adds it, with its icon,
to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone
who just wants to run the file. who just wants to run the file.
## Updating
When the server has a newer release, the top bar says "Version X is available". Clicking
it offers **Update and restart**: the game downloads its new file from the release
(`MusicPlayer_<version>.exe` on Windows, `MusicPlayer_<version>.x86_64` on Linux, through
the server's `/download/<platform>` routes), puts it in place of the running file and
starts it, and the quiz continues where it was. On Windows the old file is kept as
`<game>.old` until the next start. So keep the release file names as they are: the server
finds them by their ending. Where the game can't replace itself (run from the editor, a
folder it can't write to, or another platform), it opens the download in the browser
instead.
+145
View File
@@ -0,0 +1,145 @@
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:
stop(node, key)
if !enabled() || !node.is_inside_tree():
return null
var new_tween: Tween = node.create_tween()
node.set_meta(StringName("_tween_" + key), new_tween)
return new_tween
## Stops the animation called [param key] on [param node], if one is running.
static func stop(node: Node, key: StringName = &"anim") -> void:
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()
## 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
+118
View File
@@ -0,0 +1,118 @@
class_name AnimatedProgressBar
extends ProgressBar
## A progress bar with some life in it:
## - [method wait] shows Godot's busy animation until there is any progress;
## - [method move_to] glides to each new value instead of jumping;
## - a soft highlight sweeps along the filled part, kept inside its rounded corners.
##
## The bar's style, e.g. [code]SyncProgressBar[/code], has a [code]shine[/code] colour for
## the highlight, and two more styles go with it: [code]<style>Fill[/code], a Panel shaped
## like the filled part that the highlight is kept inside, and [code]<style>Text[/code],
## the Label with the percentage. With Reduce animations the bar only jumps to its values,
## with no busy animation or highlight.
## How wide the highlight is, in pixels.
const SHINE_WIDTH: float = 260.0
## How fast the highlight moves, in pixels per second.
const SHINE_SPEED: float = 520.0
## How far past the end the highlight goes before starting over, so sweeps have a pause.
const SHINE_GAP: float = 600.0
# The filled part's shape, which only its children are drawn inside
var _mask: Panel
var _shine: TextureRect
# The percentage, drawn above the highlight rather than under it
var _label: Label
var _shine_x: float = -SHINE_WIDTH
func _ready() -> void:
_mask = Panel.new()
_mask.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mask.clip_children = CanvasItem.CLIP_CHILDREN_ONLY
add_child(_mask)
_shine = TextureRect.new()
_shine.mouse_filter = Control.MOUSE_FILTER_IGNORE
_shine.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_shine.stretch_mode = TextureRect.STRETCH_SCALE
_mask.add_child(_shine)
_label = Label.new()
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_label.visible = show_percentage
add_child(_label)
show_percentage = false
_mask.theme_type_variation = StringName(String(theme_type_variation) + "Fill")
_label.theme_type_variation = StringName(String(theme_type_variation) + "Text")
theme_changed.connect(_update_style)
value_changed.connect(func(_value: float) -> void: _update_label())
_update_style()
_update_label()
func _process(delta: float) -> void:
var fill_width: float = fill_width()
_mask.size = Vector2(fill_width, size.y)
_mask.visible = fill_width > 0.0 && !indeterminate && Anim.enabled()
if !_mask.visible:
return
_shine_x += SHINE_SPEED * delta
if _shine_x > fill_width + SHINE_GAP:
_shine_x = -SHINE_WIDTH
_shine.position = Vector2(_shine_x, 0.0)
_shine.size = Vector2(SHINE_WIDTH, size.y)
## Shows the busy animation, with no value, until [method move_to] has some progress.
func wait() -> void:
Anim.stop(self, &"progress")
value = min_value
indeterminate = Anim.enabled()
_update_label()
## Glides to [param target]. Any progress ends the busy animation.
func move_to(target: float) -> void:
if target > min_value:
indeterminate = false
var glide: Tween = Anim.tween(self, &"progress")
if glide == null:
value = target
return
glide.tween_property(self, "value", target, Anim.SLOW) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
## How wide the filled part is, the way [ProgressBar] draws it.
func fill_width() -> float:
var margins: float = get_theme_stylebox(&"fill").get_minimum_size().x
var filled: float = roundf(ratio_done() * (size.x - margins))
return filled + margins if filled > 0.0 else 0.0
func _update_style() -> void:
var shine: Color = get_theme_color(&"shine")
var clear: Color = shine
clear.a = 0.0
var gradient: Gradient = Gradient.new()
gradient.offsets = PackedFloat32Array([0.0, 0.5, 1.0])
gradient.colors = PackedColorArray([clear, shine, clear])
var texture: GradientTexture2D = GradientTexture2D.new()
texture.gradient = gradient
texture.width = 256
texture.height = 1
_shine.texture = texture
func _update_label() -> void:
if _label == null:
return
_label.text = "" if indeterminate else "%d%%" % roundi(ratio_done() * 100.0)
## How much is done, from 0 to 1.
func ratio_done() -> float:
var span: float = max_value - min_value
return clampf((value - min_value) / span, 0.0, 1.0) if span > 0.0 else 0.0
+1
View File
@@ -0,0 +1 @@
uid://bxjx3kacfqg6j
+29 -1
View File
@@ -4,6 +4,7 @@ extends RefCounted
## shrinks while it is held down, and can pop when its icon changes. ## 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. ## 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. ## How big a button is under the mouse.
const HOVER_SCALE: Vector2 = Vector2(1.08, 1.08) 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 _TWEEN_META: StringName = &"button_bounce_tween"
const _PIVOT_META: StringName = &"button_bounce_pivot" 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: static func add_to(button: BaseButton) -> void:
if button.has_meta(_ADDED_META):
return
button.set_meta(_ADDED_META, true)
_keep_pivot_centred(button) _keep_pivot_centred(button)
button.mouse_entered.connect(func() -> void: button.mouse_entered.connect(func() -> void:
if !button.disabled: 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. ## Shrinks [param button] and lets it spring back, e.g. when its icon changes.
static func pop(button: BaseButton) -> void: static func pop(button: BaseButton) -> void:
if !Anim.enabled():
return
_keep_pivot_centred(button) _keep_pivot_centred(button)
button.scale = POP_SCALE button.scale = POP_SCALE
var tween: Tween = _new_tween(button) 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: static func _scale_to(button: BaseButton, target: Vector2) -> void:
if !Anim.enabled():
button.scale = Vector2.ONE
return
var tween: Tween = _new_tween(button) var tween: Tween = _new_tween(button)
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(button, ^"scale", target, DURATION) tween.tween_property(button, ^"scale", target, DURATION)
+19 -62
View File
@@ -14,15 +14,15 @@ extends RefCounted
## can't be reached at all, the cached pictures are used as they are. ## can't be reached at all, the cached pictures are used as they are.
const FOLDER_NAME: String = "characters" const FOLDER_NAME: String = "characters"
const INDEX_FILE_NAME: String = "index.json" const INDEX_FILE_NAME: String = FileCache.INDEX_FILE_NAME
## How long a picture is used before downloading it again, when the server can't tell ## How long a picture is used before downloading it again, when the server can't tell
## whether it changed. ## whether it changed.
const MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60 const MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60
## The change time of a picture when the server can't tell it. ## The change time of a picture when the server can't tell it.
const UNKNOWN_TIME: int = -1 const UNKNOWN_TIME: int = FileCache.UNKNOWN_TIME
# What index.json holds: name -> {"modified": int, "downloaded": int} # The pictures on disk and their index
var _index: Dictionary = {} var _files: FileCache = FileCache.new(FOLDER_NAME)
# When the server last changed each picture on the list, or UNKNOWN_TIME # When the server last changed each picture on the list, or UNKNOWN_TIME
var _modified: Dictionary[String, int] = {} var _modified: Dictionary[String, int] = {}
# True when the server could not be reached and only cached pictures are shown # True when the server could not be reached and only cached pictures are shown
@@ -34,11 +34,16 @@ static func folder() -> String:
return Settings.config_dir().path_join(FOLDER_NAME) return Settings.config_dir().path_join(FOLDER_NAME)
## Deletes every cached picture. They are downloaded again when next asked for.
func clear() -> void:
_files.clear()
## Fetches the names of the characters and calls [param on_done] with them as an ## Fetches the names of the characters and calls [param on_done] with them as an
## [code]Array[String][/code]. Cached pictures of characters no longer on the server are ## [code]Array[String][/code]. Cached pictures of characters no longer on the server are
## deleted. ## deleted.
func load_list(on_done: Callable) -> void: func load_list(on_done: Callable) -> void:
_load_index() _files.load_index()
_cache_only = false _cache_only = false
var details_fetched: Callable = func details_fetched(details: Array) -> void: var details_fetched: Callable = func details_fetched(details: Array) -> void:
_modified.clear() _modified.clear()
@@ -51,9 +56,8 @@ func load_list(on_done: Callable) -> void:
## Calls [param on_done] with the picture of [param character_name] as an [Image], from ## Calls [param on_done] with the picture of [param character_name] as an [Image], from
## disk when the cached one is still fresh, otherwise from the server. ## disk when the cached one is still fresh, otherwise from the server.
func fetch_image(character_name: String, on_done: Callable) -> void: func fetch_image(character_name: String, on_done: Callable) -> void:
var path: String = _file_path(character_name) if (_cache_only || _is_fresh(character_name)) && _files.has_file(character_name):
if (_cache_only || _is_fresh(character_name)) && FileAccess.file_exists(path): var cached: Image = CharacterCard.image_from_bytes(_files.read(character_name))
var cached: Image = CharacterCard.image_from_bytes(FileAccess.get_file_as_bytes(path))
if cached != null: if cached != null:
on_done.call(cached) on_done.call(cached)
return return
@@ -63,7 +67,7 @@ func fetch_image(character_name: String, on_done: Callable) -> void:
if picture == null: if picture == null:
GameLog.warning("Could not load the picture of ", character_name) GameLog.warning("Could not load the picture of ", character_name)
return return
_store(character_name, bytes) _files.store(character_name, bytes, _modified.get(character_name, UNKNOWN_TIME))
on_done.call(picture) on_done.call(picture)
MusicServerClient.fetch_character_image(character_name, image_fetched) MusicServerClient.fetch_character_image(character_name, image_fetched)
@@ -84,71 +88,24 @@ func _load_cached_only(on_done: Callable) -> void:
GameLog.warning("Could not get the characters from the server, showing the cached ones") GameLog.warning("Could not get the characters from the server, showing the cached ones")
_cache_only = true _cache_only = true
var names: Array[String] = [] var names: Array[String] = []
for character_name: String in _index: for character_name: String in _files.keys():
if FileAccess.file_exists(_file_path(character_name)): if _files.has_file(character_name):
names.append(character_name) names.append(character_name)
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0) names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
on_done.call(names) on_done.call(names)
func _list_loaded(on_done: Callable) -> void: func _list_loaded(on_done: Callable) -> void:
var removed: bool = false _files.keep_only(_modified)
for character_name: String in _index.keys():
if !_modified.has(character_name):
DirAccess.remove_absolute(_file_path(character_name))
_index.erase(character_name)
removed = true
if removed:
_save_index()
var names: Array[String] = [] var names: Array[String] = []
names.assign(_modified.keys()) names.assign(_modified.keys())
on_done.call(names) on_done.call(names)
func _is_fresh(character_name: String) -> bool: func _is_fresh(character_name: String) -> bool:
if !_index.has(character_name): if !_files.keys().has(character_name):
return false return false
var entry: Dictionary = _index[character_name]
var modified: int = _modified.get(character_name, UNKNOWN_TIME) var modified: int = _modified.get(character_name, UNKNOWN_TIME)
if modified != UNKNOWN_TIME: if modified != UNKNOWN_TIME:
return int(entry.get("modified", UNKNOWN_TIME)) == modified return _files.modified(character_name) == modified
var age: float = Time.get_unix_time_from_system() - float(entry.get("downloaded", 0)) return _files.age(character_name) < MAX_AGE_SECONDS
return age < MAX_AGE_SECONDS
func _store(character_name: String, bytes: PackedByteArray) -> void:
DirAccess.make_dir_recursive_absolute(folder())
var file: FileAccess = FileAccess.open(_file_path(character_name), FileAccess.WRITE)
if file == null:
GameLog.warning("Could not cache the picture of ", character_name, ": ",
error_string(FileAccess.get_open_error()))
return
file.store_buffer(bytes)
file.close()
_index[character_name] = {
"modified": _modified.get(character_name, UNKNOWN_TIME),
"downloaded": int(Time.get_unix_time_from_system()),
}
_save_index()
func _load_index() -> void:
_index = {}
var path: String = folder().path_join(INDEX_FILE_NAME)
if FileAccess.file_exists(path):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if parsed is Dictionary:
_index = parsed
func _save_index() -> void:
var file: FileAccess = FileAccess.open(folder().path_join(INDEX_FILE_NAME),
FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(_index, "\t", true))
# The server's names are plain file names, but they are made safe anyway since they
# become paths on this computer
func _file_path(character_name: String) -> String:
return folder().path_join(character_name.validate_filename())
+149
View File
@@ -0,0 +1,149 @@
class_name CoverCache
extends RefCounted
## Soundtrack covers kept on disk in the [code]covers[/code] folder next to the settings,
## by soundtrack UUID, so each one is downloaded only when it changed on the server (its
## [code]cover_modified[/code] time). See [FileCache].
##
## Covers are downloaded a few at a time, in the order they are asked for, so opening the
## statistics doesn't send a request for every soundtrack at once.
##
## It also remembers each soundtrack's cover time from the soundtrack lists it is given,
## so [method fetch_cover_of] finds a cover by the soundtrack alone, e.g. in the quiz.
## Emitted when the cover time of a soundtrack is new or changed, e.g. once the soundtrack
## list has loaded or a cover was changed.
signal cover_times_changed
const FOLDER_NAME: String = "covers"
## How many covers are downloaded at the same time.
const MAX_DOWNLOADS: int = 3
# The covers on disk and their index
var _files: FileCache = FileCache.new(FOLDER_NAME)
# Covers waiting to be downloaded: [uuid, modified]
var _waiting: Array[Array] = []
# Who is waiting for each cover that is asked for or downloading: uuid -> Array[Callable]
var _callbacks: Dictionary = {}
var _downloading: int = 0
# The server's cover time of each soundtrack seen: uuid -> int, 0 for none
var _times: Dictionary = {}
func _init() -> void:
_files.load_index()
## The folder the covers are kept in.
static func folder() -> String:
return Settings.config_dir().path_join(FOLDER_NAME)
## Deletes every cached cover. They are downloaded again when next asked for.
func clear() -> void:
_files.clear()
## Deletes the covers of soundtracks that are not in [param covers] (UUID -> cover
## time), and those whose soundtrack no longer has a cover.
func keep_only(covers: Dictionary) -> void:
set_cover_times(covers)
var kept: Dictionary = {}
for uuid: String in covers:
if int(covers[uuid]) > 0:
kept[uuid] = true
_files.keep_only(kept)
## Remembers the cover times in [param covers] (UUID -> cover time, 0 for no cover) and
## emits [signal cover_times_changed] when any is new or changed.
func set_cover_times(covers: Dictionary) -> void:
var changed: bool = false
for uuid: String in covers:
var modified: int = int(covers[uuid])
if _times.get(uuid, -1) != modified:
_times[uuid] = modified
changed = true
if changed:
cover_times_changed.emit()
## The cover time of the soundtrack [param uuid], 0 when it has no cover or none is known.
func cover_time(uuid: String) -> int:
return _times.get(uuid, 0)
## Like [method fetch_cover], with the cover time remembered for the soundtrack.
func fetch_cover_of(uuid: String, on_done: Callable) -> void:
fetch_cover(uuid, cover_time(uuid), on_done)
## Asks the server for the cover time of the soundtrack [param uuid] again, e.g. after
## its cover was changed, then calls [param on_done] with the new time, 0 for no cover.
## A removed cover is deleted from disk too. When the server doesn't answer, it is called
## with the time it had.
func refresh(uuid: String, on_done: Callable) -> void:
var with_soundtrack: Callable = func with_soundtrack(
soundtrack: SoundtrackWithSongList) -> void:
if soundtrack.cover_modified <= 0:
_files.remove(uuid)
set_cover_times({uuid: soundtrack.cover_modified})
on_done.call(soundtrack.cover_modified)
# Without an answer the cover is left as it was
MusicServerClient.fetch_soundtrack(uuid, with_soundtrack,
func() -> void: on_done.call(cover_time(uuid)))
## Calls [param on_done] with the cover of the soundtrack [param uuid] as an [Image]:
## from disk when the cached one has the server's time [param modified], otherwise once
## it has been downloaded. Nothing is called when the soundtrack has no cover
## ([param modified] 0) or the download fails.
func fetch_cover(uuid: String, modified: int, on_done: Callable) -> void:
if modified <= 0 || uuid.is_empty():
return
if _files.has_file(uuid) && _files.modified(uuid) == modified:
var cached: Image = CharacterCard.image_from_bytes(_files.read(uuid))
if cached != null:
on_done.call(cached)
return
if _callbacks.has(uuid):
_callbacks[uuid].append(on_done)
return
_callbacks[uuid] = [on_done]
_waiting.append([uuid, modified])
_download_next()
## How many covers are waiting for their turn or downloading.
func busy() -> int:
return _callbacks.size()
func _download_next() -> void:
while _downloading < MAX_DOWNLOADS && !_waiting.is_empty():
var next: Array = _waiting.pop_front()
var uuid: String = next[0]
_downloading += 1
MusicServerClient.fetch_soundtrack_cover(uuid,
_on_downloaded.bind(uuid, next[1]), _on_failed.bind(uuid))
func _on_downloaded(bytes: PackedByteArray, uuid: String, modified: int) -> void:
_downloading -= 1
var cover: Image = CharacterCard.image_from_bytes(bytes)
if cover == null:
GameLog.warning("The cover of soundtrack ", uuid, " could not be read")
else:
_files.store(uuid, bytes, modified)
for on_done: Callable in _callbacks.get(uuid, []):
# A card may be gone by the time its cover arrives
if cover != null && on_done.is_valid():
on_done.call(cover)
_callbacks.erase(uuid)
_download_next()
# A 404 means the server has no cover for it yet
func _on_failed(uuid: String) -> void:
_downloading -= 1
_callbacks.erase(uuid)
_download_next()
+1
View File
@@ -0,0 +1 @@
uid://djxm2kkq8hol1
+140
View File
@@ -0,0 +1,140 @@
class_name FileCache
extends RefCounted
## Downloaded files kept in a folder next to the settings, with an [code]index.json[/code]
## that records for each file when the server last changed it and when it was
## downloaded:
## [codeblock]
## {"Crono.png": {"modified": 1730000000, "downloaded": 1730100000}}
## [/codeblock]
## Used by [CharacterCache] and [CoverCache], which decide when a file is fresh.
const INDEX_FILE_NAME: String = "index.json"
## The change time of a file when the server can't tell it.
const UNKNOWN_TIME: int = -1
## The folder's name in [method Settings.config_dir].
var folder_name: String
# What index.json holds: key -> {"modified": int, "downloaded": int}
var _index: Dictionary = {}
func _init(new_folder_name: String) -> void:
folder_name = new_folder_name
## The folder the files are kept in.
func folder() -> String:
return Settings.config_dir().path_join(folder_name)
## Reads [code]index.json[/code]. A missing or broken index starts empty.
func load_index() -> void:
_index = {}
var path: String = folder().path_join(INDEX_FILE_NAME)
if FileAccess.file_exists(path):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if parsed is Dictionary:
_index = parsed
## The keys of the files in the index.
func keys() -> Array:
return _index.keys()
## Whether the file for [param key] is in the index and on disk.
func has_file(key: String) -> bool:
return _index.has(key) && FileAccess.file_exists(file_path(key))
## When the server last changed the file for [param key], as recorded when it was
## downloaded, or [constant UNKNOWN_TIME].
func modified(key: String) -> int:
return int(_index.get(key, {}).get("modified", UNKNOWN_TIME))
## How many seconds ago the file for [param key] was downloaded.
func age(key: String) -> float:
return Time.get_unix_time_from_system() - float(_index.get(key, {}).get("downloaded", 0))
## The file for [param key].
func read(key: String) -> PackedByteArray:
return FileAccess.get_file_as_bytes(file_path(key))
## Writes [param bytes] as the file for [param key], last changed on the server at
## [param modified_time], and saves the index. Returns false if it could not be written.
func store(key: String, bytes: PackedByteArray, modified_time: int) -> bool:
DirAccess.make_dir_recursive_absolute(folder())
var file: FileAccess = FileAccess.open(file_path(key), FileAccess.WRITE)
if file == null:
GameLog.warning("Could not cache ", key, " in ", folder_name, ": ",
error_string(FileAccess.get_open_error()))
return false
file.store_buffer(bytes)
file.close()
_index[key] = {
"modified": modified_time,
"downloaded": int(Time.get_unix_time_from_system()),
}
save_index()
return true
## Deletes the file for [param key] and its entry, and saves the index.
func remove(key: String) -> void:
DirAccess.remove_absolute(file_path(key))
if _index.erase(key):
save_index()
## Deletes every file whose key is not in [param kept], e.g. files of things the server
## no longer has.
func keep_only(kept: Dictionary) -> void:
var removed: bool = false
for key: String in _index.keys():
if !kept.has(key):
DirAccess.remove_absolute(file_path(key))
_index.erase(key)
removed = true
if removed:
save_index()
## Deletes every file in the folder, the index too, e.g. to free the space they take.
func clear() -> void:
_index = {}
if !DirAccess.dir_exists_absolute(folder()):
return
for file_name: String in DirAccess.get_files_at(folder()):
DirAccess.remove_absolute(folder().path_join(file_name))
## How many bytes the files in [param dir] take, leaving out the paths in [param skip]
## and any subfolders. 0 when the folder doesn't exist.
static func folder_size(dir: String, skip: PackedStringArray = []) -> int:
if !DirAccess.dir_exists_absolute(dir):
return 0
var total: int = 0
for file_name: String in DirAccess.get_files_at(dir):
var path: String = dir.path_join(file_name)
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file != null && !skip.has(path):
total += file.get_length()
return total
func save_index() -> void:
DirAccess.make_dir_recursive_absolute(folder())
var file: FileAccess = FileAccess.open(folder().path_join(INDEX_FILE_NAME),
FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(_index, "\t", true))
## Where the file for [param key] is. Keys come from the server, so they are made safe
## as file names.
func file_path(key: String) -> String:
return folder().path_join(key.validate_filename())
+1
View File
@@ -0,0 +1 @@
uid://0jfq23w54ihw
+47
View File
@@ -0,0 +1,47 @@
class_name FolderCharacters
extends RefCounted
## Characters read from a folder on this computer instead of the server: every PNG, JPG
## or WebP picture in it is a character, named by its file name.
##
## Has the same two calls as [CharacterCache], so the character select popup uses either.
## The file endings of the pictures that are characters.
const PICTURE_EXTENSIONS: Array[String] = ["png", "jpg", "jpeg", "webp"]
## The folder the pictures are in.
var path: String = ""
func _init(folder_path: String) -> void:
path = folder_path
## The file names of the pictures in [param folder], in natural order. Hidden files and
## other files are left out.
static func picture_names(folder: String) -> Array[String]:
var names: Array[String] = []
for file_name: String in DirAccess.get_files_at(folder):
if !file_name.begins_with(".") \
&& PICTURE_EXTENSIONS.has(file_name.get_extension().to_lower()):
names.append(file_name)
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
return names
## Calls [param on_done] with the names of the characters as an [code]Array[String][/code].
func load_list(on_done: Callable) -> void:
on_done.call(picture_names(path))
## Calls [param on_done] with the picture of [param character_name] as an [Image]. Nothing
## is called when the picture is gone or can't be read.
func fetch_image(character_name: String, on_done: Callable) -> void:
var file: String = path.path_join(character_name.validate_filename())
if !FileAccess.file_exists(file):
GameLog.warning("The picture of ", character_name, " is not in ", path)
return
var picture: Image = CharacterCard.image_from_bytes(FileAccess.get_file_as_bytes(file))
if picture == null:
GameLog.warning("Could not read the picture ", file)
return
on_done.call(picture)
+1
View File
@@ -0,0 +1 @@
uid://bc2nkmd162ibl
+33 -8
View File
@@ -5,23 +5,36 @@ extends RefCounted
## letters also find the letters in order with others in between. ## letters also find the letters in order with others in between.
## ##
## Used by the Search popup and the statistics view, so both find the same soundtracks. ## Used by the Search popup and the statistics view, so both find the same soundtracks.
##
## A soundtrack is also found by its alternative names, e.g. "FF7" for "Final Fantasy
## VII". The results are always the real names; [method matched_alternative] tells which
## alternative name matched.
## Each name as a Dictionary with its original, lowercase and cleaned form. ## Each searchable name as a Dictionary: the soundtrack's real name ("original"), the name
## searched in ("text"), and its lowercase and cleaned forms.
var _names: Array[Dictionary] = [] var _names: Array[Dictionary] = []
# The alternative name that matched in the last search, by real name
var _matched_alternatives: Dictionary = {}
## Compiled patterns for the letters-in-order search, by cleaned search term. ## Compiled patterns for the letters-in-order search, by cleaned search term.
var _regex_cache: Dictionary = {} var _regex_cache: Dictionary = {}
## Replaces the names to search in. ## Replaces the names to search in. [param alternatives] has the other names of a
func set_names(names: Array) -> void: ## soundtrack, by its real name.
func set_names(names: Array, alternatives: Dictionary = {}) -> void:
_names.clear() _names.clear()
_matched_alternatives.clear()
for soundtrack: String in names: for soundtrack: String in names:
_names.append({ _add_name(soundtrack, soundtrack)
"original": soundtrack, for alternative: String in alternatives.get(soundtrack, []):
"lowercase": soundtrack.to_lower(), _add_name(soundtrack, alternative)
"cleaned": clean_term(soundtrack),
})
## The alternative name of [param soundtrack_name] that matched in the last
## [method find_matches], or an empty String when its real name matched.
func matched_alternative(soundtrack_name: String) -> String:
return _matched_alternatives.get(soundtrack_name, "")
## Returns the names matching [param search_text], best matches first: plain ## Returns the names matching [param search_text], best matches first: plain
@@ -41,11 +54,14 @@ func find_matches(search_text: String) -> Array[String]:
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null) func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null)
var matches: Array[String] = [] var matches: Array[String] = []
_matched_alternatives.clear()
for matches_pass: Callable in passes: for matches_pass: Callable in passes:
for entry: Dictionary in _names: for entry: Dictionary in _names:
if !seen.has(entry.original) && matches_pass.call(entry): if !seen.has(entry.original) && matches_pass.call(entry):
seen[entry.original] = true seen[entry.original] = true
matches.append(entry.original) matches.append(entry.original)
if entry.text != entry.original:
_matched_alternatives[entry.original] = entry.text
return matches return matches
@@ -107,6 +123,15 @@ static func clean_term(term: String) -> String:
.replace("'n", "and").to_lower() .replace("'n", "and").to_lower()
func _add_name(soundtrack: String, text: String) -> void:
_names.append({
"original": soundtrack,
"text": text,
"lowercase": text.to_lower(),
"cleaned": clean_term(text),
})
# Compiles the letters-in-order pattern for [param search_term], once per term # Compiles the letters-in-order pattern for [param search_term], once per term
func _compile_regex_cached(search_term: String) -> RegEx: func _compile_regex_cached(search_term: String) -> RegEx:
if not search_term in _regex_cache: if not search_term in _regex_cache:
+174
View File
@@ -0,0 +1,174 @@
class_name Updater
extends Node
## Updates the game in place to the newest release: downloads the new game next to the
## running one, checks it, swaps the files and starts the new version.
##
## The game is one file on Windows and Linux (the game data is embedded). A running .exe
## can't be overwritten on Windows, but it can be renamed, so it is moved aside to
## [code]<exe>.old[/code], which [method remove_old_files] deletes at the next start. On
## Linux the new file simply replaces the running one.
##
## [member executable_path], [member os_name], [member in_editor], [member start_process]
## and [member quit] can be replaced, so tests choose the platform and nothing restarts.
## Where the newest release can be downloaded by hand.
const RELEASES_PAGE: String = "https://gitea.sanplex.xyz/Sansan/MusicPlayer/releases/latest"
## Added to the game's path for the downloaded new version.
const UPDATE_SUFFIX: String = ".update"
## Added to the game's path for the old version, on Windows.
const OLD_SUFFIX: String = ".old"
## rwxr-xr-x, for the new game on Linux.
const EXECUTABLE_PERMISSIONS: int = 0b111_101_101
## Seconds the whole download may take.
const DOWNLOAD_TIMEOUT: float = 900.0
## The running game's file.
var executable_path: String = OS.get_executable_path()
## The platform, as [method OS.get_name] names it.
var os_name: String = OS.get_name()
## Whether the game runs from the Godot editor, where there is no game file to replace.
var in_editor: bool = OS.has_feature("editor")
## Starts a program: [code](path: String, arguments: PackedStringArray) -> int[/code].
var start_process: Callable = OS.create_process
## Quits the game.
var quit: Callable
var _request: HTTPRequest
var _on_progress: Callable
func _init() -> void:
quit = func() -> void: get_tree().quit()
func _process(_delta: float) -> void:
if _request != null && _on_progress.is_valid():
_on_progress.call(_request.get_downloaded_bytes(), _request.get_body_size())
## The release file to download for [param os_name]: the game itself to update in place,
## or, when not [param in_place], what a person downloads, which on Linux is the package
## with an installer. Empty when there is no release for the platform.
static func asset_for(for_os: String, in_place: bool) -> String:
match for_os:
"Windows":
return "windows"
"Linux":
return "linux" if in_place else "linux-package"
return ""
## Why the game can't update itself here, or empty when it can.
func can_update_in_place() -> String:
if in_editor:
return "The game runs from the Godot editor."
if asset_for(os_name, true).is_empty():
return "The game updates itself on Windows and Linux."
var probe: String = executable_path + ".write-test"
var file: FileAccess = FileAccess.open(probe, FileAccess.WRITE)
if file == null:
return "The game's folder %s can't be written to." % executable_path.get_base_dir()
file.close()
DirAccess.remove_absolute(probe)
return ""
## Where the new version is downloaded to, next to the game so it can be renamed into
## place.
func update_path() -> String:
return executable_path + UPDATE_SUFFIX
## Downloads the new game from [param url]. Calls [param on_progress] with the bytes
## downloaded and the total (-1 while unknown) every frame, then [param on_done] with the
## file's path once it is checked, or [param on_failed] with the reason.
func download(url: String, on_progress: Callable, on_done: Callable,
on_failed: Callable) -> void:
cancel()
_on_progress = on_progress
_request = HTTPRequest.new()
_request.download_file = update_path()
_request.timeout = DOWNLOAD_TIMEOUT
add_child(_request)
_request.request_completed.connect(_on_downloaded.bind(on_done, on_failed))
var error: Error = _request.request(url)
if error != OK:
cancel()
on_failed.call("The download could not start: " + error_string(error))
## Stops a download and deletes what was downloaded.
func cancel() -> void:
if _request != null:
_request.cancel_request()
_request.queue_free()
_request = null
DirAccess.remove_absolute(update_path())
## Whether [param path] looks like the game for [param for_os]: a Windows program starts
## with "MZ", a Linux one with the ELF header. An error page saved by mistake does not.
static func check_file(path: String, for_os: String) -> bool:
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null || file.get_length() < 4:
return false
var start: PackedByteArray = file.get_buffer(4)
match for_os:
"Windows":
return start.slice(0, 2) == "MZ".to_ascii_buffer()
"Linux":
return start == PackedByteArray([0x7F, 0x45, 0x4C, 0x46])
return false
## Puts the new game at [param update] in place of the game at [param exe]. On Windows
## the running game is moved aside first, and put back if the new one can't be moved in.
static func install(update: String, exe: String, for_os: String) -> Error:
if for_os == "Windows":
var old: String = exe + OLD_SUFFIX
if FileAccess.file_exists(old):
DirAccess.remove_absolute(old)
var error: Error = DirAccess.rename_absolute(exe, old)
if error != OK:
return error
error = DirAccess.rename_absolute(update, exe)
if error != OK:
DirAccess.rename_absolute(old, exe)
return error
FileAccess.set_unix_permissions(update, EXECUTABLE_PERMISSIONS)
return DirAccess.rename_absolute(update, exe)
## Deletes what an update left next to the game at [param exe]: the old version, and a
## download that was not finished.
static func remove_old_files(exe: String) -> void:
for suffix: String in [OLD_SUFFIX, UPDATE_SUFFIX]:
if FileAccess.file_exists(exe + suffix):
DirAccess.remove_absolute(exe + suffix)
## Starts the game again, now the new version, and quits this one.
func restart() -> void:
start_process.call(executable_path, OS.get_cmdline_args())
quit.call()
func _on_downloaded(result: int, response_code: int, _headers: PackedStringArray,
_body: PackedByteArray, on_done: Callable, on_failed: Callable) -> void:
var expected: int = _request.get_body_size()
_request.queue_free()
_request = null
var reason: String = ""
if result != HTTPRequest.RESULT_SUCCESS:
reason = "The download failed. Check the connection and try again."
elif response_code != 200:
reason = "The download failed: the server answered %d." % response_code
elif expected >= 0 && FileAccess.get_size(update_path()) != expected:
reason = "The download stopped before the end. Try again."
elif !check_file(update_path(), os_name):
reason = "The downloaded file is not the game for %s." % os_name
if !reason.is_empty():
DirAccess.remove_absolute(update_path())
on_failed.call(reason)
return
on_done.call(update_path())
+1
View File
@@ -0,0 +1 @@
uid://bu3bi7qhxr7it
+44 -2
View File
@@ -8,19 +8,25 @@ extends RefCounted
## order or at random, and only the song's name is the answer. ## order or at random, and only the song's name is the answer.
## ##
## Hidden files and folders, and files the game can't play (see [AudioFile]), are left ## Hidden files and folders, and files the game can't play (see [AudioFile]), are left
## out. ## out. A subfolder called [code]characters[/code] holds the quiz's own characters
## instead of songs, see [member characters_path].
enum Kind { EMPTY, SOUNDTRACKS, PREPARED_QUIZ } enum Kind { EMPTY, SOUNDTRACKS, PREPARED_QUIZ }
enum PlayOrder { IN_ORDER, RANDOM } enum PlayOrder { IN_ORDER, RANDOM }
## How many files are tried before giving up when files can't be read. ## How many files are tried before giving up when files can't be read.
const MAX_TRIES: int = 20 const MAX_TRIES: int = 20
## The subfolder with the quiz's own characters. Any case, e.g. "Characters", works.
const CHARACTERS_FOLDER: String = "characters"
## The folder's path. ## The folder's path.
var path: String = "" var path: String = ""
var kind: Kind = Kind.EMPTY var kind: Kind = Kind.EMPTY
## How a prepared quiz is played. Change it with [method set_play_order]. ## How a prepared quiz is played. Change it with [method set_play_order].
var play_order: PlayOrder = PlayOrder.IN_ORDER var play_order: PlayOrder = PlayOrder.IN_ORDER
## The folder's [constant CHARACTERS_FOLDER] subfolder when it has pictures, so a themed
## quiz can bring its own characters. Empty when there is none.
var characters_path: String = ""
# Soundtrack folder: soundtrack name -> paths of its songs # Soundtrack folder: soundtrack name -> paths of its songs
var _soundtracks: Dictionary[String, PackedStringArray] = {} var _soundtracks: Dictionary[String, PackedStringArray] = {}
@@ -35,7 +41,14 @@ var _quiz_next: int = 0
static func scan(folder_path: String) -> MusicFolder: static func scan(folder_path: String) -> MusicFolder:
var folder: MusicFolder = MusicFolder.new() var folder: MusicFolder = MusicFolder.new()
folder.path = folder_path.trim_suffix("/") folder.path = folder_path.trim_suffix("/")
var subfolders: PackedStringArray = _visible(DirAccess.get_directories_at(folder.path)) var subfolders: PackedStringArray = []
for subfolder: String in _visible(DirAccess.get_directories_at(folder.path)):
if subfolder.to_lower() == CHARACTERS_FOLDER:
var characters: String = folder.path.path_join(subfolder)
if !FolderCharacters.picture_names(characters).is_empty():
folder.characters_path = characters
else:
subfolders.append(subfolder)
if !subfolders.is_empty(): if !subfolders.is_empty():
for subfolder: String in subfolders: for subfolder: String in subfolders:
var songs: PackedStringArray = _songs_in(folder.path.path_join(subfolder), true) var songs: PackedStringArray = _songs_in(folder.path.path_join(subfolder), true)
@@ -86,6 +99,35 @@ func restart() -> void:
_quiz_next = 0 _quiz_next = 0
## How far a prepared quiz has come, to continue it after a restart with
## [method restore_progress].
func progress() -> Dictionary:
return {"play_order": play_order, "queue": Array(_quiz_queue), "next": _quiz_next}
## Continues a prepared quiz from [param saved], as given by [method progress]. Songs
## that are no longer in the folder are left out. Returns false, changing nothing, when
## [param saved] does not fit this folder.
func restore_progress(saved: Dictionary) -> bool:
if kind != Kind.PREPARED_QUIZ || !(saved.get("queue") is Array) \
|| !(saved.get("next") is float || saved.get("next") is int):
return false
var queue: PackedStringArray = []
var next: int = int(saved.next)
var known: Dictionary = {}
for song_path: String in _quiz_songs:
known[song_path] = true
for i: int in saved.queue.size():
if known.has(str(saved.queue[i])):
queue.append(str(saved.queue[i]))
elif i < int(saved.next):
next -= 1
play_order = int(saved.get("play_order", play_order)) as PlayOrder
_quiz_queue = queue
_quiz_next = clampi(next, 0, queue.size())
return true
## The next song to play, or null when a prepared quiz has played every song or no file ## The next song to play, or null when a prepared quiz has played every song or no file
## can be read. ## can be read.
func next_song() -> SongObject: func next_song() -> SongObject:
+36
View File
@@ -21,6 +21,9 @@ var id: String = _generate_uuid()
var is_first_point: bool = true var is_first_point: bool = true
var character: Texture = load(NO_CHARACTER_PATH) var character: Texture = load(NO_CHARACTER_PATH)
## The name of the picked character, its picture's file name, or empty when none is
## picked. Saved with the quiz, so the picture can be found again after a restart.
var character_name: String = ""
func _init(new_name: String) -> void: func _init(new_name: String) -> void:
@@ -28,6 +31,39 @@ func _init(new_name: String) -> void:
player_score = 0 player_score = 0
## Shows [param texture], the character called [param new_name], for the player.
func set_character(texture: Texture, new_name: String) -> void:
character = texture
character_name = new_name
## Goes back to no character, e.g. when the characters come from somewhere else.
func clear_character() -> void:
character = load(NO_CHARACTER_PATH)
character_name = ""
## The player as a Dictionary that can be written as JSON, for [QuizSave].
func to_dict() -> Dictionary:
return {
"id": id, "name": player_name, "score": player_score,
"is_first_point": is_first_point, "character": character_name,
}
## A player made from [param data], as given by [method to_dict], or null when
## [param data] is not a player. The character's picture is not loaded here.
static func from_dict(data: Variant) -> PlayerObject:
if !(data is Dictionary) || !(data.get("name") is String) || !(data.get("id") is String):
return null
var player: PlayerObject = PlayerObject.new(data.name)
player.id = data.id
player.player_score = maxi(int(data.get("score", 0)), 0)
player.is_first_point = bool(data.get("is_first_point", player.player_score == 0))
player.character_name = str(data.get("character", ""))
return player
## Returns a random version 4 UUID, e.g. "3f2b8c1e-9a4d-4e7f-b2c6-5d8e1f0a7b93". ## Returns a random version 4 UUID, e.g. "3f2b8c1e-9a4d-4e7f-b2c6-5d8e1f0a7b93".
static func _generate_uuid() -> String: static func _generate_uuid() -> String:
var bytes: PackedByteArray = Crypto.new().generate_random_bytes(16) var bytes: PackedByteArray = Crypto.new().generate_random_bytes(16)
+25 -9
View File
@@ -5,8 +5,9 @@ extends Node
## Registered as the [code]Playlist[/code] autoload. ## Registered as the [code]Playlist[/code] autoload.
## ##
## Downloaded songs are kept on disk in the [code]songs[/code] folder next to the ## Downloaded songs are kept on disk in the [code]songs[/code] folder next to the
## settings, not in memory. The folder only holds the current playlist: it is emptied when ## settings, not in memory. The folder only holds the current playlist: when the game
## the game starts and when the playlist is reset. ## starts, songs the saved quiz does not use are deleted (see [QuizSave]), and the folder
## is emptied when the playlist is reset.
const SONGS_FOLDER_NAME: String = "songs" const SONGS_FOLDER_NAME: String = "songs"
@@ -25,10 +26,6 @@ var game_start_song: int = 0
var _song_files_written: int = 0 var _song_files_written: int = 0
func _ready() -> void:
clear_song_files()
## The folder downloaded songs are kept in. ## The folder downloaded songs are kept in.
static func songs_folder() -> String: static func songs_folder() -> String:
return Settings.config_dir().path_join(SONGS_FOLDER_NAME) return Settings.config_dir().path_join(SONGS_FOLDER_NAME)
@@ -111,6 +108,10 @@ func get_current_soundtrack_title() -> String:
return song_object_array[currently_playing_song].get_soundtrack_title() return song_object_array[currently_playing_song].get_soundtrack_title()
func get_current_soundtrack_uuid() -> String:
return song_object_array[currently_playing_song].get_soundtrack_uuid()
func get_current_song_title() -> String: func get_current_song_title() -> String:
return song_object_array[currently_playing_song].get_song_title() return song_object_array[currently_playing_song].get_song_title()
@@ -152,7 +153,11 @@ func add_song_to_song_object(song_no: int, mp3: PackedByteArray) -> void:
## written, the song is kept in memory instead. ## written, the song is kept in memory instead.
func store_song(song_object: SongObject, mp3: PackedByteArray) -> void: func store_song(song_object: SongObject, mp3: PackedByteArray) -> void:
DirAccess.make_dir_recursive_absolute(songs_folder()) DirAccess.make_dir_recursive_absolute(songs_folder())
# Songs kept from a saved quiz already have files, which must not be written over
var path: String = songs_folder().path_join("song_%d.mp3" % _song_files_written) var path: String = songs_folder().path_join("song_%d.mp3" % _song_files_written)
while FileAccess.file_exists(path):
_song_files_written += 1
path = songs_folder().path_join("song_%d.mp3" % _song_files_written)
_song_files_written += 1 _song_files_written += 1
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
if file == null: if file == null:
@@ -169,12 +174,23 @@ func store_song(song_object: SongObject, mp3: PackedByteArray) -> void:
song_object.song_path = path song_object.song_path = path
## Deletes the files of the songs downloaded so far. ## Deletes the files of the songs downloaded so far, except those in [param keep].
func clear_song_files() -> void: ## The downloaded song files the playlist uses, which must be kept while it does.
func song_files_in_use() -> PackedStringArray:
var files: PackedStringArray = []
for song_object: SongObject in song_object_array:
if !song_object.song_path.is_empty():
files.append(song_object.song_path)
return files
func clear_song_files(keep: PackedStringArray = []) -> void:
if !DirAccess.dir_exists_absolute(songs_folder()): if !DirAccess.dir_exists_absolute(songs_folder()):
return return
for file_name: String in DirAccess.get_files_at(songs_folder()): for file_name: String in DirAccess.get_files_at(songs_folder()):
DirAccess.remove_absolute(songs_folder().path_join(file_name)) var path: String = songs_folder().path_join(file_name)
if !keep.has(path):
DirAccess.remove_absolute(path)
func song_has_played(song_no: int = latest_played_song) -> void: func song_has_played(song_no: int = latest_played_song) -> void:
+174
View File
@@ -0,0 +1,174 @@
class_name QuizSave
extends RefCounted
## The quiz going on, saved to a file after every change, so the game goes on where it
## was when it is started again: the players with their points and characters, and every
## song with who got a point for it and whether it was played, answered or is playing.
##
## Songs downloaded ahead keep their files, so they are not downloaded again. A new game
## (Reset points, a sync or another server) is saved like any other change, so the file
## always holds the quiz as it is now.
##
## Kept in [code]current_quiz.json[/code] next to the settings, or
## [code]current_quiz_debug.json[/code] in debug mode, like the saved settings.
const FILE_NAME: String = "current_quiz.json"
const DEBUG_FILE_NAME: String = "current_quiz_debug.json"
## Files with another version are from a game that saved differently, and are ignored.
const VERSION: int = 1
var players: Array[PlayerObject] = []
var songs: Array[SongObject] = []
var currently_playing_song: int = -1
var latest_played_song: int = 0
var game_start_song: int = 0
var first_song_played: bool = false
## Whether statistics were turned off because a player won, see [code]Quiz[/code].
var stats_paused_by_win: bool = false
## The server the songs came from. Empty when they came from a music folder.
var server: String = ""
## The music folder the songs came from, or empty when they came from the server.
var music_folder_path: String = ""
## How far a prepared quiz in [member music_folder_path] has come, see
## [method MusicFolder.progress].
var music_folder_progress: Dictionary = {}
## Where the characters came from, see [method Settings.characters_dir].
var characters_dir: String = ""
## Where the quiz is saved.
static func path() -> String:
return Settings.config_dir().path_join(DEBUG_FILE_NAME if Settings.is_debug else FILE_NAME)
## The quiz as it is now, in [code]Settings[/code] and [code]Playlist[/code].
static func capture(paused_by_win: bool) -> QuizSave:
var save: QuizSave = QuizSave.new()
save.players = Settings.player_array.duplicate()
save.songs = Playlist.song_object_array.duplicate()
save.currently_playing_song = Playlist.currently_playing_song
save.latest_played_song = Playlist.latest_played_song
save.game_start_song = Playlist.game_start_song
save.first_song_played = Playlist.first_song_played
save.stats_paused_by_win = paused_by_win
if Settings.is_offline():
save.music_folder_path = Settings.music_folder.path
save.music_folder_progress = Settings.music_folder.progress()
else:
save.server = Settings.default_path
save.characters_dir = Settings.characters_dir()
return save
## Reads the saved quiz, or returns null when there is none or it can't be read.
static func read() -> QuizSave:
if !FileAccess.file_exists(path()):
return null
var save: QuizSave = from_dict(JSON.parse_string(FileAccess.get_file_as_string(path())))
if save == null:
GameLog.warning("Could not read the saved quiz in ", path(), ", starting a new one")
return save
## Deletes the saved quiz.
static func delete() -> void:
if FileAccess.file_exists(path()):
DirAccess.remove_absolute(path())
## Saves the quiz. It is written to another file first and then moved, so a crash while
## saving never leaves half a file. Returns false when it could not be saved.
func write() -> bool:
var target: String = path()
var temporary: String = target + ".tmp"
DirAccess.make_dir_recursive_absolute(target.get_base_dir())
var file: FileAccess = FileAccess.open(temporary, FileAccess.WRITE)
if file == null:
GameLog.warning("Could not save the quiz to ", temporary, ": ",
error_string(FileAccess.get_open_error()))
return false
file.store_string(JSON.stringify(to_dict(), "\t"))
file.close()
var error: Error = DirAccess.rename_absolute(temporary, target)
if error != OK:
GameLog.warning("Could not save the quiz to ", target, ": ", error_string(error))
return false
return true
## The song files the saved songs use, which must not be deleted.
func song_files() -> PackedStringArray:
var files: PackedStringArray = []
for song_object: SongObject in songs:
if !song_object.song_path.is_empty():
files.append(song_object.song_path)
return files
func to_dict() -> Dictionary:
var player_list: Array[Dictionary] = []
for player: PlayerObject in players:
player_list.append(player.to_dict())
var song_list: Array[Dictionary] = []
for song_object: SongObject in songs:
song_list.append(song_object.to_dict())
return {
"version": VERSION,
"players": player_list,
"songs": song_list,
"currently_playing_song": currently_playing_song,
"latest_played_song": latest_played_song,
"game_start_song": game_start_song,
"first_song_played": first_song_played,
"stats_paused_by_win": stats_paused_by_win,
"server": server,
"music_folder": {"path": music_folder_path, "progress": music_folder_progress},
"characters_dir": characters_dir,
}
## A save made from [param data], as given by [method to_dict], or null when
## [param data] is not a saved quiz of this [constant VERSION]. Players and songs that
## can't be read are left out.
static func from_dict(data: Variant) -> QuizSave:
if !(data is Dictionary) || int(data.get("version", 0)) != VERSION \
|| !(data.get("players") is Array) || !(data.get("songs") is Array):
return null
var save: QuizSave = QuizSave.new()
for player_data: Variant in data.players:
var player: PlayerObject = PlayerObject.from_dict(player_data)
if player != null && save.players.size() < Settings.MAX_NUMBER_OF_PLAYERS:
save.players.append(player)
for song_data: Variant in data.songs:
var song_object: SongObject = SongObject.from_dict(song_data)
if song_object == null:
# The songs' places in the list are their numbers, so none can be left out
save.songs.clear()
break
save.songs.append(song_object)
save.currently_playing_song = clampi(int(data.get("currently_playing_song", -1)), -1,
save.songs.size() - 1)
save.latest_played_song = clampi(int(data.get("latest_played_song", 0)), 0,
maxi(save.songs.size() - 1, 0))
save.game_start_song = clampi(int(data.get("game_start_song", 0)), 0, save.songs.size())
save.first_song_played = bool(data.get("first_song_played", false))
save.stats_paused_by_win = bool(data.get("stats_paused_by_win", false))
save.server = str(data.get("server", ""))
var folder: Variant = data.get("music_folder", {})
if folder is Dictionary:
save.music_folder_path = str(folder.get("path", ""))
if folder.get("progress") is Dictionary:
save.music_folder_progress = folder.progress
save.characters_dir = str(data.get("characters_dir", ""))
return save
## Forgets the songs, e.g. when the server's playlist no longer has them. The players are
## kept, without points.
func drop_songs() -> void:
songs.clear()
currently_playing_song = -1
latest_played_song = 0
game_start_song = 0
first_song_played = false
for player: PlayerObject in players:
player.reset_points()
+1
View File
@@ -0,0 +1 @@
uid://cfabuhf854ukp
+63 -3
View File
@@ -3,6 +3,13 @@ extends Node
## ##
## Registered as the [code]Settings[/code] autoload. ## Registered as the [code]Settings[/code] autoload.
## Emitted when another theme is used, for code that reads theme colours itself.
signal theme_changed
## How the server picks a random song: any soundtrack then any of its songs, preferring
## soundtracks played less than average, or every song with the same chance.
enum RandomMode { NORMAL, LOW_PLAYED, CLASSIC }
const MAX_NUMBER_OF_PLAYERS: int = 7 const MAX_NUMBER_OF_PLAYERS: int = 7
## How many letters a search needs before it also finds names with the letters in order ## How many letters a search needs before it also finds names with the letters in order
## but apart, e.g. "lttp" for "A Link to the Past". Shorter searches only find names ## but apart, e.g. "lttp" for "A Link to the Past". Shorter searches only find names
@@ -23,11 +30,19 @@ const SAVED_SETTINGS: Dictionary[String, Array] = {
"quiz": [ "quiz": [
&"auto_repeat_song", &"stop_after_current", &"hide_next_track", &"hide_beginning", &"auto_repeat_song", &"stop_after_current", &"hide_next_track", &"hide_beginning",
&"hide_for_seconds", &"hide_length", &"hide_ticks", &"winning_score", &"hide_for_seconds", &"hide_length", &"hide_ticks", &"winning_score",
&"inspiration_list_speed", &"inspiration_list_speed", &"short_songs", &"short_song_seconds",
], ],
"window": [&"fullscreen"], "window": [&"fullscreen"],
"app": [&"last_seen_version"], "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.
const THEMES: Array[Dictionary] = [
{"id": "classic", "name": "Classic", "path": "res://themes/main_theme.tres"},
{"id": "original_web", "name": "Original web", "path": "res://themes/original_web_theme.tres"},
{"id": "nord", "name": "Nord", "path": "res://themes/nord_theme.tres"},
{"id": "christmas", "name": "Christmas", "path": "res://themes/christmas_theme.tres"},
]
## When this environment variable is set, the settings files are kept in that folder ## When this environment variable is set, the settings files are kept in that folder
## instead of the user's config folder. The tests set it so they never touch the real files. ## instead of the user's config folder. The tests set it so they never touch the real files.
const CONFIG_DIR_ENV: String = "MUSICPLAYER_CONFIG_DIR" const CONFIG_DIR_ENV: String = "MUSICPLAYER_CONFIG_DIR"
@@ -66,7 +81,8 @@ var stop_after_current: bool = true
var auto_repeat_song: bool = true var auto_repeat_song: bool = true
var hide_next_track: bool = true var hide_next_track: bool = true
var add_to_stats: bool = true var add_to_stats: bool = true
var use_low_played_mode: bool = false ## How the server picks random songs. Not saved, like statistics counting.
var random_mode: RandomMode = RandomMode.NORMAL
var winning_score: int = 20 var winning_score: int = 20
var fullscreen: bool = false var fullscreen: bool = false
var inspiration_list_speed: int = 1 var inspiration_list_speed: int = 1
@@ -74,6 +90,10 @@ var hide_for_seconds: float = 5.0
var hide_beginning: bool = false var hide_beginning: bool = false
var hide_length: bool = false var hide_length: bool = false
var hide_ticks: bool = false var hide_ticks: bool = false
## Plays only [member short_song_seconds] of each song, from a random point, so more
## songs fit in a game.
var short_songs: bool = false
var short_song_seconds: int = 30
var player_array: Array[PlayerObject] var player_array: Array[PlayerObject]
var edit_players: bool = false var edit_players: bool = false
var currently_syncing: bool = false var currently_syncing: bool = false
@@ -85,9 +105,16 @@ var queue_ids: Dictionary[String, String] = {}
## The version the game last showed the About popup for. When it is not [member version], ## 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. ## this version is new and the main window opens the About popup to show what is new.
var last_seen_version: String = "" 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 ## The folder songs are played from instead of the server, picked in the settings, or null
## to use the server. Kept for this session only. ## to use the server. Kept for this session only.
var music_folder: MusicFolder = null var music_folder: MusicFolder = null
## The folder characters are read from instead of the server, picked in the settings, or
## empty. Kept for this session only. See [method characters_dir].
var character_folder: String = ""
## The game version, set in Project Settings > Application > Config > Version. ## The game version, set in Project Settings > Application > Config > Version.
## Releases are made from it, see RELEASING.md. ## Releases are made from it, see RELEASING.md.
@@ -100,6 +127,7 @@ var version: String = ProjectSettings.get_setting("application/config/version",
func _ready() -> void: func _ready() -> void:
apply_config(load_config()) apply_config(load_config())
load_saved_settings() load_saved_settings()
_use_theme(theme_id)
if fullscreen: if fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
@@ -233,6 +261,38 @@ func is_prepared_quiz() -> bool:
return music_folder != null && music_folder.kind == MusicFolder.Kind.PREPARED_QUIZ return music_folder != null && music_folder.kind == MusicFolder.Kind.PREPARED_QUIZ
## The folder the characters come from, or empty for the server's: the folder picked in
## the settings, otherwise the [code]characters[/code] folder of the music folder played.
func characters_dir() -> String:
if !character_folder.is_empty():
return character_folder
if music_folder != null:
return music_folder.characters_path
return ""
## Switches the whole game to the theme [param id] from [constant THEMES] and saves the
## choice. Controls without a theme of their own take it from the root window.
func apply_theme(id: String) -> void:
_use_theme(id)
save_settings()
## The theme in [constant THEMES] with [param id], or the default one if there is none.
func theme_entry(id: String) -> Dictionary:
for entry: Dictionary in THEMES:
if entry.id == id:
return entry
return THEMES[0]
func _use_theme(id: String) -> void:
var entry: Dictionary = theme_entry(id)
theme_id = entry.id
get_tree().root.theme = load(entry.path)
theme_changed.emit()
func delete_children(node: Node) -> void: func delete_children(node: Node) -> void:
for n: Node in node.get_children(): for n: Node in node.get_children():
node.remove_child(n) node.remove_child(n)
+41
View File
@@ -5,6 +5,8 @@ class_name SongObject
var song_number: int var song_number: int
var song_title: String var song_title: String
var soundtrack_title: String var soundtrack_title: String
## The UUID of the song's soundtrack on the server, empty for songs from a music folder.
var soundtrack_uuid: String = ""
## Ids of the players who got a point for this song. ## Ids of the players who got a point for this song.
var players_given_point: Array[String] = [] var players_given_point: Array[String] = []
@@ -17,6 +19,9 @@ var is_playing: bool = false
var song_path: String = "" var song_path: String = ""
## The song itself, only when it could not be saved to a file. ## The song itself, only when it could not be saved to a file.
var song: AudioStream var song: AudioStream
## Where the part played with Short songs starts, in seconds, or -1 before it is picked.
## Picked once, so restarting and repeating play the same part.
var short_start: float = -1.0
func get_soundtrack_title() -> String: func get_soundtrack_title() -> String:
@@ -33,6 +38,11 @@ func get_song_title() -> String:
return "??????" return "??????"
## The soundtrack's UUID once the answer is shown, otherwise empty.
func get_soundtrack_uuid() -> String:
return soundtrack_uuid if is_answered else ""
func get_song_info() -> String: func get_song_info() -> String:
if Settings.is_debug: if Settings.is_debug:
return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(), return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(),
@@ -61,6 +71,37 @@ func remove_point(player_id: String) -> void:
players_given_point.erase(player_id) players_given_point.erase(player_id)
## The song as a Dictionary that can be written as JSON, for [QuizSave]. A song kept only
## in memory is saved without its file.
func to_dict() -> Dictionary:
return {
"number": song_number, "soundtrack": soundtrack_title, "song": song_title,
"points": players_given_point.duplicate(), "answered": is_answered,
"played": has_played, "playing": is_playing, "path": song_path,
"soundtrack_uuid": soundtrack_uuid,
}
## A song made from [param data], as given by [method to_dict], or null when [param data]
## is not a song.
static func from_dict(data: Variant) -> SongObject:
if !(data is Dictionary) || !(data.get("number") is float || data.get("number") is int):
return null
var song_object: SongObject = SongObject.new()
song_object.song_number = int(data.number)
song_object.soundtrack_title = str(data.get("soundtrack", ""))
song_object.song_title = str(data.get("song", ""))
song_object.soundtrack_uuid = str(data.get("soundtrack_uuid", ""))
if data.get("points") is Array:
for player_id: Variant in data.points:
song_object.add_point(str(player_id))
song_object.is_answered = bool(data.get("answered", false))
song_object.has_played = bool(data.get("played", false))
song_object.is_playing = bool(data.get("playing", false))
song_object.song_path = str(data.get("path", ""))
return song_object
func _to_string() -> String: func _to_string() -> String:
var format_string: String = "song_number: %d | soundtrack_title: %s | song_title: %s" \ var format_string: String = "song_number: %d | soundtrack_title: %s | song_title: %s" \
+ " | is_answered: %s | has_played: %s | is_playing: %s" + " | is_answered: %s | has_played: %s | is_playing: %s"
+3 -263
View File
@@ -75,266 +75,6 @@ debug/export_console_script=1
[preset.1] [preset.1]
name="macOS"
platform="macOS"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="tests/*, addons/gdUnit4/*, linux/*"
export_path="./MusicPlayer_0.9_Beta.dmg"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
export/distribution_type=1
binary_format/architecture="universal"
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
application/icon=""
application/icon_interpolation=4
application/bundle_identifier="tech.sanplex.musicplayer"
application/signature=""
application/app_category="Music-games"
application/short_version=""
application/version=""
application/copyright=""
application/copyright_localized={}
application/min_macos_version_x86_64="10.12"
application/min_macos_version_arm64="11.00"
application/export_angle=0
display/high_res=true
application/additional_plist_content=""
xcode/platform_build="14C18"
xcode/sdk_version="13.1"
xcode/sdk_build="22C55"
xcode/sdk_name="macosx13.1"
xcode/xcode_version="1420"
xcode/xcode_build="14C18"
codesign/codesign=1
codesign/installer_identity=""
codesign/apple_team_id=""
codesign/identity=""
codesign/entitlements/custom_file=""
codesign/entitlements/allow_jit_code_execution=false
codesign/entitlements/allow_unsigned_executable_memory=false
codesign/entitlements/allow_dyld_environment_variables=false
codesign/entitlements/disable_library_validation=false
codesign/entitlements/audio_input=false
codesign/entitlements/camera=false
codesign/entitlements/location=false
codesign/entitlements/address_book=false
codesign/entitlements/calendars=false
codesign/entitlements/photos_library=false
codesign/entitlements/apple_events=false
codesign/entitlements/debugging=false
codesign/entitlements/app_sandbox/enabled=false
codesign/entitlements/app_sandbox/network_server=false
codesign/entitlements/app_sandbox/network_client=false
codesign/entitlements/app_sandbox/device_usb=false
codesign/entitlements/app_sandbox/device_bluetooth=false
codesign/entitlements/app_sandbox/files_downloads=0
codesign/entitlements/app_sandbox/files_pictures=0
codesign/entitlements/app_sandbox/files_music=0
codesign/entitlements/app_sandbox/files_movies=0
codesign/entitlements/app_sandbox/files_user_selected=0
codesign/entitlements/app_sandbox/helper_executables=[]
codesign/entitlements/additional=""
codesign/custom_options=PackedStringArray()
notarization/notarization=0
privacy/microphone_usage_description=""
privacy/microphone_usage_description_localized={}
privacy/camera_usage_description=""
privacy/camera_usage_description_localized={}
privacy/location_usage_description=""
privacy/location_usage_description_localized={}
privacy/address_book_usage_description=""
privacy/address_book_usage_description_localized={}
privacy/calendar_usage_description=""
privacy/calendar_usage_description_localized={}
privacy/photos_library_usage_description=""
privacy/photos_library_usage_description_localized={}
privacy/desktop_folder_usage_description=""
privacy/desktop_folder_usage_description_localized={}
privacy/documents_folder_usage_description=""
privacy/documents_folder_usage_description_localized={}
privacy/downloads_folder_usage_description=""
privacy/downloads_folder_usage_description_localized={}
privacy/network_volumes_usage_description=""
privacy/network_volumes_usage_description_localized={}
privacy/removable_volumes_usage_description=""
privacy/removable_volumes_usage_description_localized={}
privacy/tracking_enabled=false
privacy/tracking_domains=PackedStringArray()
privacy/collected_data/name/collected=false
privacy/collected_data/name/linked_to_user=false
privacy/collected_data/name/used_for_tracking=false
privacy/collected_data/name/collection_purposes=0
privacy/collected_data/email_address/collected=false
privacy/collected_data/email_address/linked_to_user=false
privacy/collected_data/email_address/used_for_tracking=false
privacy/collected_data/email_address/collection_purposes=0
privacy/collected_data/phone_number/collected=false
privacy/collected_data/phone_number/linked_to_user=false
privacy/collected_data/phone_number/used_for_tracking=false
privacy/collected_data/phone_number/collection_purposes=0
privacy/collected_data/physical_address/collected=false
privacy/collected_data/physical_address/linked_to_user=false
privacy/collected_data/physical_address/used_for_tracking=false
privacy/collected_data/physical_address/collection_purposes=0
privacy/collected_data/other_contact_info/collected=false
privacy/collected_data/other_contact_info/linked_to_user=false
privacy/collected_data/other_contact_info/used_for_tracking=false
privacy/collected_data/other_contact_info/collection_purposes=0
privacy/collected_data/health/collected=false
privacy/collected_data/health/linked_to_user=false
privacy/collected_data/health/used_for_tracking=false
privacy/collected_data/health/collection_purposes=0
privacy/collected_data/fitness/collected=false
privacy/collected_data/fitness/linked_to_user=false
privacy/collected_data/fitness/used_for_tracking=false
privacy/collected_data/fitness/collection_purposes=0
privacy/collected_data/payment_info/collected=false
privacy/collected_data/payment_info/linked_to_user=false
privacy/collected_data/payment_info/used_for_tracking=false
privacy/collected_data/payment_info/collection_purposes=0
privacy/collected_data/credit_info/collected=false
privacy/collected_data/credit_info/linked_to_user=false
privacy/collected_data/credit_info/used_for_tracking=false
privacy/collected_data/credit_info/collection_purposes=0
privacy/collected_data/other_financial_info/collected=false
privacy/collected_data/other_financial_info/linked_to_user=false
privacy/collected_data/other_financial_info/used_for_tracking=false
privacy/collected_data/other_financial_info/collection_purposes=0
privacy/collected_data/precise_location/collected=false
privacy/collected_data/precise_location/linked_to_user=false
privacy/collected_data/precise_location/used_for_tracking=false
privacy/collected_data/precise_location/collection_purposes=0
privacy/collected_data/coarse_location/collected=false
privacy/collected_data/coarse_location/linked_to_user=false
privacy/collected_data/coarse_location/used_for_tracking=false
privacy/collected_data/coarse_location/collection_purposes=0
privacy/collected_data/sensitive_info/collected=false
privacy/collected_data/sensitive_info/linked_to_user=false
privacy/collected_data/sensitive_info/used_for_tracking=false
privacy/collected_data/sensitive_info/collection_purposes=0
privacy/collected_data/contacts/collected=false
privacy/collected_data/contacts/linked_to_user=false
privacy/collected_data/contacts/used_for_tracking=false
privacy/collected_data/contacts/collection_purposes=0
privacy/collected_data/emails_or_text_messages/collected=false
privacy/collected_data/emails_or_text_messages/linked_to_user=false
privacy/collected_data/emails_or_text_messages/used_for_tracking=false
privacy/collected_data/emails_or_text_messages/collection_purposes=0
privacy/collected_data/photos_or_videos/collected=false
privacy/collected_data/photos_or_videos/linked_to_user=false
privacy/collected_data/photos_or_videos/used_for_tracking=false
privacy/collected_data/photos_or_videos/collection_purposes=0
privacy/collected_data/audio_data/collected=false
privacy/collected_data/audio_data/linked_to_user=false
privacy/collected_data/audio_data/used_for_tracking=false
privacy/collected_data/audio_data/collection_purposes=0
privacy/collected_data/gameplay_content/collected=false
privacy/collected_data/gameplay_content/linked_to_user=false
privacy/collected_data/gameplay_content/used_for_tracking=false
privacy/collected_data/gameplay_content/collection_purposes=0
privacy/collected_data/customer_support/collected=false
privacy/collected_data/customer_support/linked_to_user=false
privacy/collected_data/customer_support/used_for_tracking=false
privacy/collected_data/customer_support/collection_purposes=0
privacy/collected_data/other_user_content/collected=false
privacy/collected_data/other_user_content/linked_to_user=false
privacy/collected_data/other_user_content/used_for_tracking=false
privacy/collected_data/other_user_content/collection_purposes=0
privacy/collected_data/browsing_history/collected=false
privacy/collected_data/browsing_history/linked_to_user=false
privacy/collected_data/browsing_history/used_for_tracking=false
privacy/collected_data/browsing_history/collection_purposes=0
privacy/collected_data/search_hhistory/collected=false
privacy/collected_data/search_hhistory/linked_to_user=false
privacy/collected_data/search_hhistory/used_for_tracking=false
privacy/collected_data/search_hhistory/collection_purposes=0
privacy/collected_data/user_id/collected=false
privacy/collected_data/user_id/linked_to_user=false
privacy/collected_data/user_id/used_for_tracking=false
privacy/collected_data/user_id/collection_purposes=0
privacy/collected_data/device_id/collected=false
privacy/collected_data/device_id/linked_to_user=false
privacy/collected_data/device_id/used_for_tracking=false
privacy/collected_data/device_id/collection_purposes=0
privacy/collected_data/purchase_history/collected=false
privacy/collected_data/purchase_history/linked_to_user=false
privacy/collected_data/purchase_history/used_for_tracking=false
privacy/collected_data/purchase_history/collection_purposes=0
privacy/collected_data/product_interaction/collected=false
privacy/collected_data/product_interaction/linked_to_user=false
privacy/collected_data/product_interaction/used_for_tracking=false
privacy/collected_data/product_interaction/collection_purposes=0
privacy/collected_data/advertising_data/collected=false
privacy/collected_data/advertising_data/linked_to_user=false
privacy/collected_data/advertising_data/used_for_tracking=false
privacy/collected_data/advertising_data/collection_purposes=0
privacy/collected_data/other_usage_data/collected=false
privacy/collected_data/other_usage_data/linked_to_user=false
privacy/collected_data/other_usage_data/used_for_tracking=false
privacy/collected_data/other_usage_data/collection_purposes=0
privacy/collected_data/crash_data/collected=false
privacy/collected_data/crash_data/linked_to_user=false
privacy/collected_data/crash_data/used_for_tracking=false
privacy/collected_data/crash_data/collection_purposes=0
privacy/collected_data/performance_data/collected=false
privacy/collected_data/performance_data/linked_to_user=false
privacy/collected_data/performance_data/used_for_tracking=false
privacy/collected_data/performance_data/collection_purposes=0
privacy/collected_data/other_diagnostic_data/collected=false
privacy/collected_data/other_diagnostic_data/linked_to_user=false
privacy/collected_data/other_diagnostic_data/used_for_tracking=false
privacy/collected_data/other_diagnostic_data/collection_purposes=0
privacy/collected_data/environment_scanning/collected=false
privacy/collected_data/environment_scanning/linked_to_user=false
privacy/collected_data/environment_scanning/used_for_tracking=false
privacy/collected_data/environment_scanning/collection_purposes=0
privacy/collected_data/hands/collected=false
privacy/collected_data/hands/linked_to_user=false
privacy/collected_data/hands/used_for_tracking=false
privacy/collected_data/hands/collection_purposes=0
privacy/collected_data/head/collected=false
privacy/collected_data/head/linked_to_user=false
privacy/collected_data/head/used_for_tracking=false
privacy/collected_data/head/collection_purposes=0
privacy/collected_data/other_data_types/collected=false
privacy/collected_data/other_data_types/linked_to_user=false
privacy/collected_data/other_data_types/used_for_tracking=false
privacy/collected_data/other_data_types/collection_purposes=0
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="#!/usr/bin/env bash
unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"
open \"{temp_dir}/{exe_name}.app\" --args {cmd_args}"
ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash
kill $(pgrep -x -f \"{temp_dir}/{exe_name}.app/Contents/MacOS/{exe_name} {cmd_args}\")
rm -rf \"{temp_dir}\""
dotnet/include_scripts_content=false
dotnet/include_debug_symbols=true
dotnet/embed_build_outputs=false
application/min_macos_version="10.12"
debug/export_console_script=1
notarization/apple_team_id=""
[preset.2]
name="Web" name="Web"
platform="Web" platform="Web"
runnable=false runnable=false
@@ -353,7 +93,7 @@ encrypt_pck=false
encrypt_directory=false encrypt_directory=false
script_export_mode=2 script_export_mode=2
[preset.2.options] [preset.1.options]
custom_template/debug="" custom_template/debug=""
custom_template/release="" custom_template/release=""
@@ -380,7 +120,7 @@ dotnet/include_scripts_content=false
dotnet/include_debug_symbols=true dotnet/include_debug_symbols=true
dotnet/embed_build_outputs=false dotnet/embed_build_outputs=false
[preset.3] [preset.2]
name="Linux" name="Linux"
platform="Linux" platform="Linux"
@@ -400,7 +140,7 @@ encrypt_pck=false
encrypt_directory=false encrypt_directory=false
script_export_mode=2 script_export_mode=2
[preset.3.options] [preset.2.options]
custom_template/debug="" custom_template/debug=""
custom_template/release="" custom_template/release=""
+10
View File
@@ -12,6 +12,7 @@
[ext_resource type="PackedScene" path="res://statistics/StatisticsView.tscn" id="statistics_view_scene"] [ext_resource type="PackedScene" path="res://statistics/StatisticsView.tscn" id="statistics_view_scene"]
[ext_resource type="PackedScene" path="res://popups/SoundtrackStatsPopup.tscn" id="soundtrack_stats_popup_scene"] [ext_resource type="PackedScene" path="res://popups/SoundtrackStatsPopup.tscn" id="soundtrack_stats_popup_scene"]
[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://popups/SettingsPopup.tscn" id="settings_popup_scene"] [ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://popups/SettingsPopup.tscn" id="settings_popup_scene"]
[ext_resource type="PackedScene" path="res://popups/UpdatePopup.tscn" id="update_popup_scene"]
[node name="MainWindow" type="Control" unique_id=407149696] [node name="MainWindow" type="Control" unique_id=407149696]
layout_mode = 3 layout_mode = 3
@@ -109,3 +110,12 @@ offset_left = 610.0
offset_top = 140.0 offset_top = 140.0
offset_right = 610.0 offset_right = 610.0
offset_bottom = 140.0 offset_bottom = 140.0
[node name="UpdatePopup" parent="." instance=ExtResource("update_popup_scene")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 640.0
offset_top = 390.0
offset_right = 640.0
offset_bottom = 390.0
+48 -42
View File
@@ -17,6 +17,7 @@ script = ExtResource("top_bar_script")
layout_mode = 0 layout_mode = 0
offset_right = 1920.0 offset_right = 1920.0
offset_bottom = 56.0 offset_bottom = 56.0
theme_type_variation = &"TopBarBackground"
[node name="Title" type="Label" parent="." unique_id=1187907861] [node name="Title" type="Label" parent="." unique_id=1187907861]
layout_mode = 0 layout_mode = 0
@@ -39,19 +40,27 @@ theme_type_variation = &"HighlightLarge"
text = "DEBUG" text = "DEBUG"
vertical_alignment = 1 vertical_alignment = 1
[node name="UpdateLabel" type="Label" parent="." unique_id=1959221258] [node name="UpdateBox" type="HBoxContainer" parent="." unique_id=1959221258]
visible = false visible = false
layout_mode = 0 layout_mode = 0
offset_left = 1096.0 offset_left = 1096.0
offset_top = 12.0 offset_top = 12.0
offset_right = 1432.0 offset_right = 1432.0
offset_bottom = 50.0 offset_bottom = 50.0
mouse_filter = 1 mouse_filter = 2
theme_type_variation = &"HighlightMedium" alignment = 2
[node name="UpdateButton" type="Button" parent="UpdateBox"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 0
mouse_default_cursor_shape = 2
theme_type_variation = &"UpdateButton"
text = "Version 0.0.0 is available" text = "Version 0.0.0 is available"
horizontal_alignment = 2
vertical_alignment = 1
text_overrun_behavior = 3 text_overrun_behavior = 3
clip_text = true
alignment = 2
[node name="OfflineLabel" type="Label" parent="." unique_id=1959221259] [node name="OfflineLabel" type="Label" parent="." unique_id=1959221259]
visible = false visible = false
@@ -67,62 +76,58 @@ horizontal_alignment = 2
vertical_alignment = 1 vertical_alignment = 1
text_overrun_behavior = 3 text_overrun_behavior = 3
[node name="SyncButton" type="Button" parent="." unique_id=1228313556] [node name="Buttons" type="HBoxContainer" parent="."]
layout_mode = 0 layout_mode = 0
offset_left = 1450.0 offset_left = 1200.0
offset_top = 8.0 offset_top = 10.0
offset_right = 1520.0 offset_right = 1856.0
offset_bottom = 48.0 offset_bottom = 46.0
mouse_filter = 2
theme_override_constants/separation = 12
alignment = 2
[node name="SyncButton" type="Button" parent="Buttons" unique_id=1228313556]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "Sync" text = "Sync"
[node name="SettingsButton" type="Button" parent="." unique_id=1228313557] [node name="SettingsButton" type="Button" parent="Buttons" unique_id=1228313557]
layout_mode = 0 unique_name_in_owner = true
offset_left = 1538.0 layout_mode = 2
offset_top = 8.0
offset_right = 1608.0
offset_bottom = 48.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "Settings" text = "Settings"
[node name="ShortcutsButton" type="Button" parent="." unique_id=2066498226] [node name="ShortcutsButton" type="Button" parent="Buttons" unique_id=2066498226]
layout_mode = 0 unique_name_in_owner = true
offset_left = 1626.0 layout_mode = 2
offset_top = 8.0
offset_right = 1696.0
offset_bottom = 48.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "Shortcuts" text = "Shortcuts"
[node name="AboutButton" type="Button" parent="." unique_id=1057035253] [node name="AboutButton" type="Button" parent="Buttons" unique_id=1057035253]
layout_mode = 0 unique_name_in_owner = true
offset_left = 1722.0 layout_mode = 2
offset_top = 8.0
offset_right = 1792.0
offset_bottom = 48.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "About" text = "About"
[node name="QRButton" type="Button" parent="." unique_id=2021908559] [node name="QRButton" type="Button" parent="Buttons" unique_id=2021908559]
layout_mode = 0 unique_name_in_owner = true
offset_left = 1802.0 layout_mode = 2
offset_top = 8.0
offset_right = 1872.0
offset_bottom = 48.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "QR" text = "QR"
[node name="QuizTab" type="Button" parent="." unique_id=1903412671] [node name="QuizTab" type="Button" parent="." unique_id=1903412671]
unique_name_in_owner = true
layout_mode = 0 layout_mode = 0
offset_left = 840.0 offset_left = 839.0
offset_top = 8.0 offset_top = 10.0
offset_right = 955.0 offset_right = 954.0
offset_bottom = 48.0 offset_bottom = 46.0
focus_mode = 0 focus_mode = 0
theme_type_variation = &"TabButton" theme_type_variation = &"TabButton"
toggle_mode = true toggle_mode = true
@@ -132,11 +137,12 @@ button_group = SubResource("ButtonGroup_tabs")
text = "Quiz" text = "Quiz"
[node name="StatisticsTab" type="Button" parent="." unique_id=1903412672] [node name="StatisticsTab" type="Button" parent="." unique_id=1903412672]
unique_name_in_owner = true
layout_mode = 0 layout_mode = 0
offset_left = 965.0 offset_left = 966.0
offset_top = 8.0 offset_top = 10.0
offset_right = 1080.0 offset_right = 1081.0
offset_bottom = 48.0 offset_bottom = 46.0
focus_mode = 0 focus_mode = 0
theme_type_variation = &"TabButton" theme_type_variation = &"TabButton"
toggle_mode = true toggle_mode = true
+47 -1
View File
@@ -5,6 +5,9 @@ extends Control
## ##
## Everything that is not part of a quiz lives here. ## 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. ## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
var popup_backdrop: PopupBackdrop var popup_backdrop: PopupBackdrop
@@ -17,12 +20,15 @@ var popup_backdrop: PopupBackdrop
@onready var about_popup: AboutPopup = %AboutPopup @onready var about_popup: AboutPopup = %AboutPopup
@onready var log_popup: LogPopup = %LogPopup @onready var log_popup: LogPopup = %LogPopup
@onready var settings_popup: SettingsPopup = %SettingsPopup @onready var settings_popup: SettingsPopup = %SettingsPopup
@onready var update_popup: UpdatePopup = %UpdatePopup
@onready var statistics_view: StatisticsView = $StatisticsView @onready var statistics_view: StatisticsView = $StatisticsView
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup @onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
func _ready() -> void: func _ready() -> void:
popup_backdrop = PopupBackdrop.add_to(self, _popups()) 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"): if OS.has_feature("macos"):
GameLog.debug("is_mac") GameLog.debug("is_mac")
quiz.search_pressed.connect(show_search) quiz.search_pressed.connect(show_search)
@@ -32,7 +38,11 @@ func _ready() -> void:
top_bar.about_pressed.connect(show_about) top_bar.about_pressed.connect(show_about)
top_bar.qr_pressed.connect(show_qr) top_bar.qr_pressed.connect(show_qr)
top_bar.settings_pressed.connect(settings_popup.open) top_bar.settings_pressed.connect(settings_popup.open)
top_bar.update_pressed.connect(update_popup.show_update)
update_popup.restarting.connect(quiz.save_now)
settings_popup.server_changed.connect(quiz.on_server_changed) settings_popup.server_changed.connect(quiz.on_server_changed)
settings_popup.characters_changed.connect(quiz.character_select_popup.load_characters)
settings_popup.clear_cache_requested.connect(clear_cache)
settings_popup.visibility_changed.connect(_on_settings_popup_visibility_changed) settings_popup.visibility_changed.connect(_on_settings_popup_visibility_changed)
sync_popup.sync_started.connect(_on_sync_started) sync_popup.sync_started.connect(_on_sync_started)
@@ -41,11 +51,41 @@ func _ready() -> void:
quiz.server_changed.connect(_on_music_source_changed) quiz.server_changed.connect(_on_music_source_changed)
quiz.event_logged.connect(log_popup.add_log_row) quiz.event_logged.connect(log_popup.add_log_row)
statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack) statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack)
soundtrack_stats_popup.covers = statistics_view.covers
quiz.now_playing.covers = statistics_view.covers
search_popup.soundtracks_loaded.connect(_remember_cover_times)
# New alternative names are found by the statistics search and the Search popup
soundtrack_stats_popup.alternative_names_changed.connect(
statistics_view.update_alternative_names)
soundtrack_stats_popup.alternative_names_changed.connect(
func(_soundtrack: Soundtrack) -> void: search_popup.get_list_of_soundtracks())
soundtrack_stats_popup.cover_changed.connect(statistics_view.update_cover)
# An update leaves the old version next to the game, to delete once the new one runs
if !OS.has_feature("editor"):
Updater.remove_old_files(OS.get_executable_path())
# A saved quiz may have been played from a music folder
top_bar.update_offline_state()
search_popup.get_list_of_soundtracks() search_popup.get_list_of_soundtracks()
show_about_if_new_version() show_about_if_new_version()
## Deletes the cached character pictures and covers. The characters are loaded again at
## once, so the character select has its pictures; covers come when they are shown.
func clear_cache() -> void:
quiz.character_select_popup.character_cache.clear()
statistics_view.covers.clear()
quiz.character_select_popup.load_characters()
# The quiz finds a soundtrack's cover by the cover times in the soundtrack list
func _remember_cover_times(soundtracks: Array[Soundtrack]) -> void:
var times: Dictionary = {}
for soundtrack: Soundtrack in soundtracks:
times[soundtrack.uuid] = soundtrack.cover_modified
statistics_view.covers.set_cover_times(times)
# After another server or a music folder was picked. Statistics need the server. # After another server or a music folder was picked. Statistics need the server.
func _on_music_source_changed() -> void: func _on_music_source_changed() -> void:
top_bar.update_offline_state() top_bar.update_offline_state()
@@ -76,7 +116,7 @@ func _input(event: InputEvent) -> void:
func _popups() -> Array[BasePopup]: func _popups() -> Array[BasePopup]:
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup, return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
soundtrack_stats_popup, settings_popup] soundtrack_stats_popup, settings_popup, update_popup]
# How many songs to download ahead can be changed in the settings. Waiting until the # How many songs to download ahead can be changed in the settings. Waiting until the
@@ -111,8 +151,14 @@ func show_qr() -> void:
func show_tab(tab: StringName) -> void: func show_tab(tab: StringName) -> void:
GameLog.debug("show_tab ", tab) GameLog.debug("show_tab ", tab)
top_bar.select_tab(tab) top_bar.select_tab(tab)
var was_statistics: bool = statistics_view.visible
statistics_view.visible = tab == &"statistics" statistics_view.visible = tab == &"statistics"
quiz.visible = !statistics_view.visible 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: if statistics_view.visible:
statistics_view.refresh() statistics_view.refresh()
+37 -13
View File
@@ -16,26 +16,40 @@ signal about_pressed
signal qr_pressed signal qr_pressed
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code]. ## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
signal tab_selected(tab: StringName) signal tab_selected(tab: StringName)
## Emitted when "Version X is available" is clicked, with the newest version.
signal update_pressed(latest_version: String)
@onready var sync_button: Button = $SyncButton ## Space between the message in the middle and the tabs and buttons around it.
const MIDDLE_GAP: float = 16.0
## The newest release the server knows of, once asked, or empty.
var latest_version: String = ""
@onready var sync_button: Button = %SyncButton
@onready var debug_label: Label = $DebugLabel @onready var debug_label: Label = $DebugLabel
@onready var update_label: Label = $UpdateLabel @onready var update_box: HBoxContainer = $UpdateBox
@onready var update_button: Button = %UpdateButton
@onready var offline_label: Label = $OfflineLabel @onready var offline_label: Label = $OfflineLabel
@onready var qr_button: Button = $QRButton @onready var qr_button: Button = %QRButton
@onready var quiz_tab: Button = $QuizTab @onready var quiz_tab: Button = %QuizTab
@onready var statistics_tab: Button = $StatisticsTab @onready var statistics_tab: Button = %StatisticsTab
func _ready() -> void: func _ready() -> void:
# Debug builds use the local server, see Settings # Debug builds use the local server, see Settings
debug_label.visible = Settings.is_debug debug_label.visible = Settings.is_debug
sync_button.pressed.connect(sync_pressed.emit) sync_button.pressed.connect(sync_pressed.emit)
$SettingsButton.pressed.connect(settings_pressed.emit) %SettingsButton.pressed.connect(settings_pressed.emit)
$ShortcutsButton.pressed.connect(shortcuts_pressed.emit) %ShortcutsButton.pressed.connect(shortcuts_pressed.emit)
$AboutButton.pressed.connect(about_pressed.emit) %AboutButton.pressed.connect(about_pressed.emit)
qr_button.pressed.connect(qr_pressed.emit) qr_button.pressed.connect(qr_pressed.emit)
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz")) quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
statistics_tab.pressed.connect(tab_selected.emit.bind(&"statistics")) statistics_tab.pressed.connect(tab_selected.emit.bind(&"statistics"))
update_button.pressed.connect(func() -> void: update_pressed.emit(latest_version))
# The buttons are as wide as the theme makes them, so the message fits between
statistics_tab.item_rect_changed.connect(_fit_middle)
sync_button.item_rect_changed.connect(_fit_middle)
_fit_middle()
update_offline_state() update_offline_state()
check_for_update() check_for_update()
@@ -51,14 +65,14 @@ func update_offline_state() -> void:
if offline: if offline:
offline_label.text = "Offline: " + Settings.music_folder.display_name() offline_label.text = "Offline: " + Settings.music_folder.display_name()
offline_label.tooltip_text = Settings.music_folder.path offline_label.tooltip_text = Settings.music_folder.path
update_label.visible = false update_box.visible = false
## Asks the server for the newest MusicPlayer release and tells the user when it is newer ## Asks the server for the newest MusicPlayer release and tells the user when it is newer
## than this one. Not asked while a music folder is used. ## than this one. Not asked while a music folder is used.
func check_for_update() -> void: func check_for_update() -> void:
if Settings.is_offline(): if Settings.is_offline():
update_label.visible = false update_box.visible = false
return return
MusicServerClient.fetch_latest_client_version(show_latest_version) MusicServerClient.fetch_latest_client_version(show_latest_version)
@@ -68,10 +82,20 @@ func check_for_update() -> void:
func show_latest_version(latest: Variant) -> void: func show_latest_version(latest: Variant) -> void:
var newer: bool = latest is String && is_newer_version(latest, Settings.version) \ var newer: bool = latest is String && is_newer_version(latest, Settings.version) \
&& !Settings.is_offline() && !Settings.is_offline()
update_label.visible = newer update_box.visible = newer
if newer: if newer:
update_label.text = "Version %s is available" % latest latest_version = latest
update_label.tooltip_text = "You have %s" % Settings.version update_button.text = "Version %s is available" % latest
update_button.tooltip_text = "You have %s. Click to update." % Settings.version
# Fits "Version X is available" and the offline label between the tabs and the buttons
func _fit_middle() -> void:
var left: float = statistics_tab.position.x + statistics_tab.size.x + MIDDLE_GAP
var right: float = sync_button.global_position.x - global_position.x - MIDDLE_GAP
for middle: Control in [update_box, offline_label]:
middle.position.x = left
middle.size.x = maxf(right - left, 0.0)
## Whether version [param latest] is newer than [param current], comparing numbers such as ## Whether version [param latest] is newer than [param current], comparing numbers such as
+6 -4
View File
@@ -109,14 +109,16 @@ theme_type_variation = &"PlayerTime"
text = "00:00 / 00:00" text = "00:00 / 00:00"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="VolumeIcon" type="TextureRect" parent="PlayerBar/MusicPlayerContainer"] [node name="VolumeIcon" type="Button" parent="PlayerBar/MusicPlayerContainer"]
custom_minimum_size = Vector2(22, 22) custom_minimum_size = Vector2(22, 22)
layout_mode = 2 layout_mode = 2
size_flags_vertical = 4 size_flags_vertical = 4
mouse_filter = 2 mouse_filter = 2
texture = ExtResource("6_volume") focus_mode = 0
expand_mode = 1 theme_type_variation = &"PlayerIcon"
stretch_mode = 5 icon = ExtResource("6_volume")
icon_alignment = 1
expand_icon = true
[node name="VolumeSlider" parent="PlayerBar/MusicPlayerContainer" instance=ExtResource("5_iifuj")] [node name="VolumeSlider" parent="PlayerBar/MusicPlayerContainer" instance=ExtResource("5_iifuj")]
unique_name_in_owner = true unique_name_in_owner = true
+101 -34
View File
@@ -5,6 +5,11 @@ extends Control
## ##
## The buttons grow a little under the mouse and the play button pops when it switches ## The buttons grow a little under the mouse and the play button pops when it switches
## between play and pause, see [ButtonBounce]. ## between play and pause, see [ButtonBounce].
##
## With Short songs on ([member Settings.short_songs]) only a part of each quiz song plays:
## [member Settings.short_song_seconds] from a random point between
## [constant SHORT_START_MIN] and [constant SHORT_START_MAX] of the song. The progress bar,
## the time and seeking all stay within that part, and its end counts as the song ending.
signal update_song_list signal update_song_list
signal play_next_song signal play_next_song
@@ -16,6 +21,15 @@ var playback_position: float
var stream: AudioStream var stream: AudioStream
var song_finished: bool = false var song_finished: bool = false
## Where in the song the part played with Short songs may start, as a share of its length.
## Many songs have a quiet intro, so the start is never right at the beginning.
const SHORT_START_MIN: float = 0.1
const SHORT_START_MAX: float = 0.6
# The part of the song that plays: its start in seconds, and its length (0: to the end)
var _clip_start: float = 0.0
var _clip_length: float = 0.0
@onready var play_button: Button = %PlayButton @onready var play_button: Button = %PlayButton
@onready var restart_button: Button = %RestartButton @onready var restart_button: Button = %RestartButton
@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer @onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer
@@ -38,14 +52,46 @@ func _ready() -> void:
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused: if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused \
if !Settings.hide_beginning \ && audio_player.playing:
|| audio_player.get_playback_position() > Settings.hide_for_seconds: var position: float = position_in_part()
progress_slider.value = audio_player.get_playback_position() if _clip_length > 0.0 && position >= _clip_length:
if stream != null: # The short part has played, which counts as the song ending
music_time_label.text = format_text(progress_slider.value, stream.get_length()) audio_player.stop()
else: progress_slider.value = _clip_length
music_time_label.text = format_text(progress_slider.value, 0.0) _on_finished()
return
if !Settings.hide_beginning || position > Settings.hide_for_seconds:
progress_slider.value = position
music_time_label.text = format_text(progress_slider.value, part_length())
## How long the part of the song that plays is: the short part, or the whole song.
func part_length() -> float:
if _clip_length > 0.0:
return _clip_length
return stream.get_length() if stream != null else 0.0
## How far into the part that plays the song is, in seconds.
func position_in_part() -> float:
return audio_player.get_playback_position() - _clip_start
## Plays only part of [param song_object]'s song when Short songs is on and the song is
## longer than the part. The part's start is picked once per song.
func choose_part(song_object: SongObject, song: AudioStream) -> void:
_clip_start = 0.0
_clip_length = 0.0
var length: float = song.get_length()
if !Settings.short_songs || song_object == null || length <= Settings.short_song_seconds:
return
if song_object.short_start < 0.0:
var latest: float = minf(length * SHORT_START_MAX, length - Settings.short_song_seconds)
var earliest: float = minf(length * SHORT_START_MIN, latest)
song_object.short_start = randf_range(earliest, latest)
_clip_start = minf(song_object.short_start, length - Settings.short_song_seconds)
_clip_length = Settings.short_song_seconds
## Whether the play button shows pause, because a song is playing. ## Whether the play button shows pause, because a song is playing.
@@ -61,6 +107,14 @@ func _show_playing(playing: bool) -> void:
ButtonBounce.pop(play_button) ButtonBounce.pop(play_button)
# The progress bar and time show the length of the part that plays
func _show_length() -> void:
var length: float = part_length()
progress_slider.max_value = round(length)
progress_slider.tick_count = 0 if Settings.hide_ticks else round(length / 60)
music_time_label.text = format_text(progress_slider.value, length)
func format_time(time: float) -> String: func format_time(time: float) -> String:
var total_seconds: int = roundi(time) var total_seconds: int = roundi(time)
@warning_ignore("integer_division") @warning_ignore("integer_division")
@@ -87,11 +141,7 @@ func play_or_pause() -> void:
song_finished = false song_finished = false
audio_player.seek(playback_position) audio_player.seek(playback_position)
GameLog.debug("continue") GameLog.debug("continue")
progress_slider.max_value = round(stream.get_length()) _show_length()
if Settings.hide_ticks:
progress_slider.tick_count = 0
else:
progress_slider.tick_count = round(stream.get_length() / 60)
else: else:
pause() pause()
@@ -102,15 +152,15 @@ func pause() -> void:
_show_playing(false) _show_playing(false)
## Plays the song, or its short part, from the beginning.
func restart() -> void: func restart() -> void:
audio_player.stop() audio_player.stop()
audio_player.stream_paused = false audio_player.stream_paused = false
song_finished = false song_finished = false
progress_slider.value = 0 progress_slider.value = 0
playback_position = audio_player.get_playback_position() playback_position = _clip_start
audio_player.seek(playback_position)
_show_playing(true) _show_playing(true)
audio_player.play() audio_player.play(_clip_start)
func _on_finished() -> void: func _on_finished() -> void:
@@ -127,16 +177,18 @@ func _on_drag_started() -> void:
func _on_drag_ended(_changed: bool) -> void: func _on_drag_ended(_changed: bool) -> void:
audio_player.seek(progress_slider.value) audio_player.seek(_clip_start + progress_slider.value)
playback_position = progress_slider.value playback_position = _clip_start + progress_slider.value
is_changing = false is_changing = false
## Jumps [param new_position] seconds forward, or back when negative, staying within the
## part that plays.
func seek(new_position: float) -> void: func seek(new_position: float) -> void:
progress_slider.value += new_position progress_slider.value += new_position
is_changing = true is_changing = true
audio_player.seek(progress_slider.value) audio_player.seek(_clip_start + progress_slider.value)
playback_position = progress_slider.value playback_position = _clip_start + progress_slider.value
is_changing = false is_changing = false
@@ -173,6 +225,9 @@ func play_sound(sound_name: AudioStream) -> void:
func play_song(song: AudioStream) -> void: func play_song(song: AudioStream) -> void:
# Sounds such as the winning song always play whole
_clip_start = 0.0
_clip_length = 0.0
audio_player.stream = song audio_player.stream = song
audio_player.play() audio_player.play()
sound_player.stop() sound_player.stop()
@@ -180,32 +235,44 @@ func play_song(song: AudioStream) -> void:
_show_playing(true) _show_playing(true)
stream = audio_player.stream stream = audio_player.stream
progress_slider.value = 0 progress_slider.value = 0
progress_slider.max_value = round(stream.get_length()) _show_length()
if Settings.hide_ticks:
progress_slider.tick_count = 0
else: ## Puts song [param song_object_no] in the player without playing it, e.g. the song that
progress_slider.tick_count = round(stream.get_length() / 60) ## was playing when the game was closed. Play starts it from the beginning.
music_time_label.text = format_text(progress_slider.value, stream.get_length()) func load_song_object(song_object_no: int) -> void:
var song: AudioStream = Playlist.get_song(song_object_no)
if song == null:
return
audio_player.stream = song
stream = song
choose_part(Playlist.song_object_array[song_object_no], song)
song_finished = true
_show_playing(false)
progress_slider.value = 0
_show_length()
func play_song_object(song_object_no: int) -> void: func play_song_object(song_object_no: int) -> void:
GameLog.debug("play_song_object") GameLog.debug("play_song_object")
var song: AudioStream = Playlist.get_song(song_object_no)
if song == null:
GameLog.warning("Song ", song_object_no, " has no file to play")
return
if audio_player.is_playing(): if audio_player.is_playing():
audio_player.stop() audio_player.stop()
await get_tree().create_timer(0.5).timeout await get_tree().create_timer(0.5).timeout
audio_player.stream = Playlist.get_song(song_object_no) audio_player.stream = song
choose_part(Playlist.song_object_array[song_object_no], song)
sound_player.stop() sound_player.stop()
audio_player.play() audio_player.play(_clip_start)
song_finished = false song_finished = false
_show_playing(true) _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 stream = audio_player.stream
progress_slider.value = 0 progress_slider.value = 0
progress_slider.max_value = round(stream.get_length()) _show_length()
if Settings.hide_ticks:
progress_slider.tick_count = 0
else:
progress_slider.tick_count = round(stream.get_length() / 60)
music_time_label.text = format_text(progress_slider.value, stream.get_length())
Playlist.set_currently_playing_song(song_object_no) Playlist.set_currently_playing_song(song_object_no)
Playlist.unset_is_playing() Playlist.unset_is_playing()
if !Settings.hide_next_track: if !Settings.hide_next_track:
+115 -4
View File
@@ -106,12 +106,71 @@ popup/item_0/id = 0
popup/item_1/text = "At random" popup/item_1/text = "At random"
popup/item_1/id = 1 popup/item_1/id = 1
[node name="CharactersRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/CharactersRow"]
layout_mode = 2
theme_type_variation = &"SettingsRowLabel"
text = "Characters"
[node name="CharactersLabel" type="Label" parent="SettingsPanel/VBoxContainer/CharactersRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 1
text = "From the server"
text_overrun_behavior = 3
[node name="ChooseCharactersButton" type="Button" parent="SettingsPanel/VBoxContainer/CharactersRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Choose…"
[node name="UseServerCharactersButton" type="Button" parent="SettingsPanel/VBoxContainer/CharactersRow"]
unique_name_in_owner = true
visible = false
layout_mode = 2
focus_mode = 0
text = "Use server"
[node name="CharactersErrorLabel" type="Label" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "No pictures found"
text_overrun_behavior = 3
[node name="ThemeRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ThemeRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Theme"
[node name="ThemeButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/ThemeRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(180, 0)
layout_mode = 2
focus_mode = 0
[node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/VBoxContainer" unique_id=400304684] [node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/VBoxContainer" unique_id=400304684]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
text = "Fullscreen" 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] [node name="CacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer" unique_id=658210492]
layout_mode = 2 layout_mode = 2
@@ -134,6 +193,7 @@ custom_minimum_size = Vector2(30, 0)
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "-1" text = "-1"
[node name="IncreaseCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1806295533] [node name="IncreaseCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1806295533]
@@ -142,14 +202,34 @@ custom_minimum_size = Vector2(30, 0)
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "+1" text = "+1"
[node name="LowPlayedCheckButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"] [node name="RandomModeRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/RandomModeRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Random songs"
[node name="RandomModeButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/RandomModeRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(180, 0)
layout_mode = 2
tooltip_text = "Normal: a random soundtrack, then one of its songs.
Low played: the same, but soundtracks played less than average come up more often.
Classic: every song has the same chance, whatever its soundtrack."
focus_mode = 0 focus_mode = 0
action_mode = 0 selected = 0
text = "Prefer less played soundtracks" item_count = 3
popup/item_0/text = "Normal"
popup/item_0/id = 0
popup/item_1/text = "Low played"
popup/item_1/id = 1
popup/item_2/text = "Classic"
popup/item_2/id = 2
[node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"] [node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
@@ -173,6 +253,27 @@ layout_mode = 2
focus_mode = 0 focus_mode = 0
text = "Open folder" text = "Open folder"
[node name="ClearCacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ClearCacheRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Downloaded pictures and songs"
[node name="CacheSizeLabel" type="Label" parent="SettingsPanel/VBoxContainer/ClearCacheRow"]
unique_name_in_owner = true
layout_mode = 2
[node name="ClearCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/ClearCacheRow"]
unique_name_in_owner = true
layout_mode = 2
tooltip_text = "Deletes the downloaded character pictures, covers and songs.
They are downloaded again when needed. Songs in the current quiz are kept."
focus_mode = 0
text = "Clear cache"
[node name="FolderDialog" type="FileDialog" parent="."] [node name="FolderDialog" type="FileDialog" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
title = "Choose a music folder" title = "Choose a music folder"
@@ -182,3 +283,13 @@ ok_button_text = "Select Current Folder"
file_mode = 2 file_mode = 2
access = 2 access = 2
use_native_dialog = true use_native_dialog = true
[node name="CharacterFolderDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Choose a folder of characters"
initial_position = 2
size = Vector2i(800, 500)
ok_button_text = "Select Current Folder"
file_mode = 2
access = 2
use_native_dialog = true
+256 -10
View File
@@ -30,19 +30,87 @@ theme_type_variation = &"Title"
horizontal_alignment = 1 horizontal_alignment = 1
text_overrun_behavior = 3 text_overrun_behavior = 3
[node name="SummaryLabel" type="Label" parent="StatsPanel" unique_id=1213000004] [node name="CoverFrame" type="Panel" parent="StatsPanel"]
unique_name_in_owner = true
clip_children = 2
layout_mode = 0
offset_left = 20.0
offset_top = 64.0
offset_right = 160.0
offset_bottom = 274.0
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
[node name="CoverPlaceholder" type="Label" parent="StatsPanel/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_type_variation = &"TitleMedium"
horizontal_alignment = 1
vertical_alignment = 1
[node name="CoverPicture" type="TextureRect" parent="StatsPanel/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Info" type="VBoxContainer" parent="StatsPanel"]
layout_mode = 0
offset_left = 180.0
offset_top = 64.0
offset_right = 680.0
offset_bottom = 264.0
[node name="SummaryLabel" type="Label" parent="StatsPanel/Info" unique_id=1213000004]
unique_name_in_owner = true
layout_mode = 2
autowrap_mode = 3
[node name="NamesTitle" type="Label" parent="StatsPanel/Info"]
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Also known as"
[node name="NamesLabel" type="Label" parent="StatsPanel/Info"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"TextMedium"
autowrap_mode = 3
text_overrun_behavior = 3
max_lines_visible = 3
[node name="InfoButtons" type="HBoxContainer" parent="StatsPanel/Info"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="EditNamesButton" type="Button" parent="StatsPanel/Info/InfoButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Edit names"
[node name="ChangeCoverButton" type="Button" parent="StatsPanel/Info/InfoButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Change cover"
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 0 layout_mode = 0
offset_left = 20.0 offset_left = 20.0
offset_top = 54.0 offset_top = 280.0
offset_right = 680.0
offset_bottom = 80.0
horizontal_alignment = 1
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
layout_mode = 0
offset_left = 20.0
offset_top = 92.0
offset_right = 680.0 offset_right = 680.0
offset_bottom = 784.0 offset_bottom = 784.0
horizontal_scroll_mode = 0 horizontal_scroll_mode = 0
@@ -51,3 +119,181 @@ horizontal_scroll_mode = 0
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
[node name="NamesEditor" type="VBoxContainer" parent="StatsPanel"]
unique_name_in_owner = true
visible = false
layout_mode = 0
offset_left = 20.0
offset_top = 280.0
offset_right = 680.0
offset_bottom = 784.0
theme_override_constants/separation = 10
[node name="EditorTitle" type="Label" parent="StatsPanel/NamesEditor"]
layout_mode = 2
theme_type_variation = &"Title"
text = "Alternative names"
[node name="EditorHelp" type="Label" parent="StatsPanel/NamesEditor"]
layout_mode = 2
autowrap_mode = 3
text = "Other names the soundtrack goes by, e.g. short names or the name in another language. Search finds it by any of them."
[node name="EditorScroll" type="ScrollContainer" parent="StatsPanel/NamesEditor"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="EditorNames" type="VBoxContainer" parent="StatsPanel/NamesEditor/EditorScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="AddRow" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="NewNameField" type="LineEdit" parent="StatsPanel/NamesEditor/AddRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Another name"
[node name="AddNameButton" type="Button" parent="StatsPanel/NamesEditor/AddRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Add"
[node name="EditorStatus" type="Label" parent="StatsPanel/NamesEditor"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
autowrap_mode = 3
[node name="EditorButtons" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
layout_mode = 2
theme_override_constants/separation = 12
alignment = 2
[node name="CancelNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Cancel"
[node name="SaveNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Save"
[node name="CoverEditor" type="VBoxContainer" parent="StatsPanel"]
unique_name_in_owner = true
visible = false
layout_mode = 0
offset_left = 20.0
offset_top = 280.0
offset_right = 680.0
offset_bottom = 784.0
theme_override_constants/separation = 10
[node name="CoverEditorTitle" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_type_variation = &"Title"
text = "Cover"
[node name="CoverEditorHelp" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
autowrap_mode = 3
text = "The server finds covers by itself. When it found the wrong one, or none, choose a picture of your own or the cover of a game on SteamGridDB or an anime on AniList. The server keeps a cover set here until it is removed."
[node name="PictureRow" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="ChoosePictureButton" type="Button" parent="StatsPanel/CoverEditor/PictureRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Choose picture…"
[node name="RemoveCoverButton" type="Button" parent="StatsPanel/CoverEditor/PictureRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Remove cover"
[node name="SourceTitle" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Pick by ID"
[node name="SourceHelp" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_type_variation = &"TextMedium"
autowrap_mode = 3
text = "The ID is the number in the game's or anime's web address."
[node name="SourceRow" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="CoverSourceButton" type="OptionButton" parent="StatsPanel/CoverEditor/SourceRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
selected = 0
item_count = 2
popup/item_0/text = "SteamGridDB"
popup/item_0/id = 0
popup/item_1/text = "AniList"
popup/item_1/id = 1
[node name="CoverIdField" type="LineEdit" parent="StatsPanel/CoverEditor/SourceRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "ID"
max_length = 50
[node name="UseSourceButton" type="Button" parent="StatsPanel/CoverEditor/SourceRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Use"
[node name="CoverEditorSpace" type="Control" parent="StatsPanel/CoverEditor"]
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="CoverStatus" type="Label" parent="StatsPanel/CoverEditor"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
autowrap_mode = 3
[node name="CoverEditorButtons" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
layout_mode = 2
alignment = 2
[node name="CloseCoverButton" type="Button" parent="StatsPanel/CoverEditor/CoverEditorButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Done"
[node name="PictureDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Choose a cover picture"
initial_position = 2
size = Vector2i(800, 500)
ok_button_text = "Open"
file_mode = 0
access = 2
filters = PackedStringArray("*.jpg, *.jpeg, *.png, *.webp ; Pictures")
use_native_dialog = true
+4 -3
View File
@@ -1,6 +1,7 @@
[gd_scene format=3 uid="uid://dpdem7pdxweb5"] [gd_scene format=3 uid="uid://dpdem7pdxweb5"]
[ext_resource type="Script" uid="uid://c3ejukwth512a" path="res://popups/sync_popup.gd" id="1_8ba47"] [ext_resource type="Script" uid="uid://c3ejukwth512a" path="res://popups/sync_popup.gd" id="1_8ba47"]
[ext_resource type="Script" path="res://common/animated_progress_bar.gd" id="progress_bar_script"]
[node name="SyncPopup" type="Control" unique_id=1302914356 node_paths=PackedStringArray("panel")] [node name="SyncPopup" type="Control" unique_id=1302914356 node_paths=PackedStringArray("panel")]
layout_mode = 3 layout_mode = 3
@@ -62,11 +63,11 @@ layout_mode = 2
[node name="SyncProgressBar" type="ProgressBar" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer" unique_id=719135231] [node name="SyncProgressBar" type="ProgressBar" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer" unique_id=719135231]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(1300, 0) custom_minimum_size = Vector2(1300, 32)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 6 size_flags_horizontal = 6
step = 1.0 theme_type_variation = &"SyncProgressBar"
rounded = true script = ExtResource("progress_bar_script")
[node name="SyncHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=1156646398] [node name="SyncHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=1156646398]
unique_name_in_owner = true unique_name_in_owner = true
+95
View File
@@ -0,0 +1,95 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://popups/update_popup.gd" id="popup_script"]
[ext_resource type="Script" path="res://common/updater.gd" id="updater_script"]
[node name="UpdatePopup" type="Control" node_paths=PackedStringArray("panel")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("popup_script")
panel = NodePath("UpdatePanel")
[node name="UpdatePanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_right = 640.0
offset_bottom = 300.0
theme_type_variation = &"PopupBackground"
[node name="Rows" type="VBoxContainer" parent="UpdatePanel"]
unique_name_in_owner = true
layout_mode = 0
offset_left = 24.0
offset_top = 16.0
offset_right = 616.0
offset_bottom = 284.0
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"Title"
text = "Version 0.0.0 is available"
horizontal_alignment = 1
[node name="InfoLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
layout_mode = 2
autowrap_mode = 3
[node name="ReasonLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
autowrap_mode = 3
[node name="DownloadProgress" type="ProgressBar" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
max_value = 1.0
show_percentage = false
[node name="StatusLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
autowrap_mode = 3
[node name="Space" type="Control" parent="UpdatePanel/Rows"]
custom_minimum_size = Vector2(0, 4)
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="Buttons" type="HBoxContainer" parent="UpdatePanel/Rows"]
layout_mode = 2
theme_override_constants/separation = 12
alignment = 2
[node name="NotNowButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Not now"
[node name="BrowserButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Download in browser"
[node name="UpdateNowButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Update and restart"
[node name="Updater" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("updater_script")
+51 -4
View File
@@ -4,32 +4,79 @@ extends Control
## ##
## Clicking outside [member panel] closes it. Popups that need to prepare or clean up ## 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]. ## 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. ## The visible part of the popup. Clicks outside it close the popup.
@export var panel: Control @export var panel: Control
## True while the popup is fading out after [method close].
var is_closing: bool = false
## Shows the popup. ## Shows the popup.
func open() -> void: func open() -> void:
is_closing = false
visible = true 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. ## Hides the popup.
func close() -> void: 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. ## Opens the popup if it is closed and closes it if it is open.
func toggle() -> void: func toggle() -> void:
if visible: if visible && !is_closing:
close() close()
else: else:
open() open()
func _hide() -> void:
is_closing = false
visible = false
panel.modulate.a = 1.0
panel.scale = Vector2.ONE
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if !visible || !(event is InputEventMouseButton) || !event.pressed: if !visible || is_closing || !(event is InputEventMouseButton) || !event.pressed:
return return
var click: InputEventMouseButton = panel.make_input_local(event) var click: InputEventMouseButton = panel.make_input_local(event)
if !Rect2(Vector2.ZERO, panel.size).has_point(click.position): if !Rect2(Vector2.ZERO, panel.size).has_point(click.position):
+10 -6
View File
@@ -3,8 +3,11 @@ extends Panel
## One character in the character select popup: its picture and name. Clicking it picks ## One character in the character select popup: its picture and name. Clicking it picks
## the character. ## the character.
## Emitted with the character's picture when the card is clicked. ## Emitted with the character's picture and name when the card is clicked.
signal new_character_selected(img_texture: ImageTexture) signal new_character_selected(img_texture: ImageTexture, character_name: String)
## The character's name: its picture's file name, e.g. "PaperMario.png".
var character_name: String = ""
@onready var character_image: TextureRect = %CharacterTextureRect @onready var character_image: TextureRect = %CharacterTextureRect
@onready var character_name_label: Label = %CharacterName @onready var character_name_label: Label = %CharacterName
@@ -19,12 +22,13 @@ func _ready() -> void:
## Shows [param character_name] and its picture. [param fetch_image] is called with the ## Shows [param character_name] and its picture. [param fetch_image] is called with the
## name and a Callable that takes the picture as an [Image], e.g. ## name and a Callable that takes the picture as an [Image], e.g.
## [method CharacterCache.fetch_image]. ## [method CharacterCache.fetch_image].
func set_character_name(character_name: String, fetch_image: Callable) -> void: func set_character_name(new_name: String, fetch_image: Callable) -> void:
character_name_label.text = display_name(character_name) character_name = new_name
character_name_label.text = display_name(new_name)
tooltip_text = character_name_label.text tooltip_text = character_name_label.text
var image_fetched: Callable = func image_fetched(picture: Image) -> void: var image_fetched: Callable = func image_fetched(picture: Image) -> void:
character_image.texture = ImageTexture.create_from_image(picture) character_image.texture = ImageTexture.create_from_image(picture)
fetch_image.call(character_name, image_fetched) fetch_image.call(new_name, image_fetched)
## The name to show for a picture's [param file_name]: without the file extension, and ## The name to show for a picture's [param file_name]: without the file extension, and
@@ -58,4 +62,4 @@ func _on_gui_input(event: InputEvent) -> void:
&& event.button_index == MOUSE_BUTTON_LEFT && Settings.character_select_open: && event.button_index == MOUSE_BUTTON_LEFT && Settings.character_select_open:
accept_event() accept_event()
GameLog.debug(character_name_label.text) GameLog.debug(character_name_label.text)
new_character_selected.emit(character_image.texture) new_character_selected.emit(character_image.texture, character_name)
+29 -6
View File
@@ -1,13 +1,24 @@
class_name CharacterSelectPopup class_name CharacterSelectPopup
extends BasePopup extends BasePopup
## The characters players pick from, from the server or from a folder on this computer
## (see [method Settings.characters_dir]).
## Emitted with the picture of the character that was picked. ## Emitted with the picture and name of the character that was picked.
signal character_selected(texture: ImageTexture) signal character_selected(texture: ImageTexture, character_name: String)
## Emitted when the characters come from somewhere else than before, e.g. a folder was
## picked. Characters picked before are not in the new set.
signal source_changed
var can_select_character: bool = false var can_select_character: bool = false
var character: PackedScene = preload("res://popups/CharacterCard.tscn") var character: PackedScene = preload("res://popups/CharacterCard.tscn")
## Keeps the pictures on disk, so only changed ones are downloaded. ## Keeps the pictures on disk, so only changed ones are downloaded.
var character_cache: CharacterCache = CharacterCache.new() var character_cache: CharacterCache = CharacterCache.new()
## Where the characters shown come from: [member character_cache] or a [FolderCharacters].
var source: RefCounted = character_cache
## The folder the characters shown come from, or empty for the server. Null before the
## characters were first loaded.
var source_dir: Variant = null
@onready var character_panel: Panel = %CharacterPanel @onready var character_panel: Panel = %CharacterPanel
@onready var title_label: Label = %TitleLabel @onready var title_label: Label = %TitleLabel
@@ -25,18 +36,30 @@ func _on_close_timeout() -> void:
close_timer.stop() close_timer.stop()
## Loads the characters from where [method Settings.characters_dir] says. Emits
## [signal source_changed] when that is somewhere else than last time.
func load_characters() -> void: func load_characters() -> void:
GameLog.debug("load_characters") GameLog.debug("load_characters")
var dir: String = Settings.characters_dir()
var changed: bool = source_dir != null && source_dir != dir
source_dir = dir
source = character_cache if dir.is_empty() else FolderCharacters.new(dir)
var loading: RefCounted = source
var list_loaded: Callable = func list_loaded(names: Array[String]) -> void: var list_loaded: Callable = func list_loaded(names: Array[String]) -> void:
GameLog.debug("list_loaded") GameLog.debug("list_loaded")
# The source changed again before the server answered
if source != loading:
return
Settings.delete_children(character_grid) Settings.delete_children(character_grid)
for character_name: String in names: for character_name: String in names:
GameLog.debug("character_name ", character_name) GameLog.debug("character_name ", character_name)
var character_box: CharacterCard = character.instantiate() var character_box: CharacterCard = character.instantiate()
character_grid.add_child(character_box) character_grid.add_child(character_box)
character_box.set_character_name(character_name, character_cache.fetch_image) character_box.set_character_name(character_name, loading.fetch_image)
character_box.new_character_selected.connect(_on_character_selected) character_box.new_character_selected.connect(_on_character_selected)
character_cache.load_list(list_loaded) loading.load_list(list_loaded)
if changed:
source_changed.emit()
func show_grid(player_name: String) -> void: func show_grid(player_name: String) -> void:
@@ -53,12 +76,12 @@ func close() -> void:
Settings.character_select_open = false Settings.character_select_open = false
func _on_character_selected(texture: ImageTexture) -> void: func _on_character_selected(texture: ImageTexture, character_name: String) -> void:
GameLog.debug("_on_character_selected") GameLog.debug("_on_character_selected")
# Not close(): character_select_open stays true until close_timer fires, so the click # Not close(): character_select_open stays true until close_timer fires, so the click
# that picked the character does not also select a song behind it # that picked the character does not also select a song behind it
visible = false visible = false
if can_select_character: if can_select_character:
character_selected.emit(texture) character_selected.emit(texture, character_name)
close_timer.start() close_timer.start()
can_select_character = false can_select_character = false
+29
View File
@@ -23,9 +23,38 @@ static func add_to(parent: Control, popups: Array[BasePopup]) -> PopupBackdrop:
for popup: BasePopup in popups: for popup: BasePopup in popups:
parent.move_child(popup, -1) parent.move_child(popup, -1)
popup.visibility_changed.connect(backdrop._update) popup.visibility_changed.connect(backdrop._update)
popup.closing.connect(backdrop._on_popup_closing)
popup.opening.connect(backdrop._on_popup_opening)
backdrop._update() backdrop._update()
return backdrop return backdrop
func _notification(what: int) -> void:
# The dimming colour comes from the theme, which can be switched in Settings
if what == NOTIFICATION_THEME_CHANGED:
color = get_theme_color(&"backdrop", &"Palette")
func _update() -> void: func _update() -> void:
var was_visible: bool = visible
visible = _popups.any(func(popup: BasePopup) -> bool: return popup.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)
+49 -3
View File
@@ -15,10 +15,23 @@ const MARGIN_BOTTOM: float = 44.0
## About how many song numbers fit along the bottom. ## About how many song numbers fit along the bottom.
const MAX_SONG_LABELS: int = 20 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 _history: ScoreHistory
var _winning_score: int = 0 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 ## 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. ## someone has more points. A winning score of 0 draws no winning line.
func show_history(history: ScoreHistory, winning_score: int) -> void: func show_history(history: ScoreHistory, winning_score: int) -> void:
@@ -27,6 +40,32 @@ func show_history(history: ScoreHistory, winning_score: int) -> void:
queue_redraw() 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. ## The colour of the player at [param index] in the player list.
func player_color(index: int) -> Color: func player_color(index: int) -> Color:
return get_theme_color(StringName("player_%d" % (index % 7 + 1)), THEME_TYPE) return get_theme_color(StringName("player_%d" % (index % 7 + 1)), THEME_TYPE)
@@ -57,13 +96,20 @@ func _draw() -> void:
var line_width: int = get_theme_constant(&"line_width", THEME_TYPE) var line_width: int = get_theme_constant(&"line_width", THEME_TYPE)
var dot_radius: int = get_theme_constant(&"dot_radius", 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(): for index: int in _history.players.size():
var color: Color = player_color(index) var color: Color = player_color(index)
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id) 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()): for song: int in range(1, scores.size()):
if scores[song] > scores[song - 1]: var dot: Vector2 = _point(song, scores[song], index)
draw_circle(_point(song, scores[song], index), dot_radius, color) 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() var label_positions: Array[Vector2] = score_label_positions()
for index: int in _history.players.size(): for index: int in _history.players.size():
draw_string(font, label_positions[index], draw_string(font, label_positions[index],
+43 -11
View File
@@ -1,7 +1,11 @@
class_name SearchPopup class_name SearchPopup
extends BasePopup extends BasePopup
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and ## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
## highlights the letters that matched. ## highlights the letters that matched. Soundtracks are also found by their alternative
## names; the alternative name that matched is shown after the real name.
## Emitted with every soundtrack on the server when the list has loaded.
signal soundtracks_loaded(list: Array[Soundtrack])
## Every soundtrack on the server, sorted by name. ## Every soundtrack on the server, sorted by name.
var soundtracks: Array = [] var soundtracks: Array = []
@@ -10,6 +14,8 @@ var _matcher: SoundtrackSearch = SoundtrackSearch.new()
# One row per soundtrack, in the order of soundtracks. Rows are reused between searches. # One row per soundtrack, in the order of soundtracks. Rows are reused between searches.
var _rows: Array[PanelContainer] = [] var _rows: Array[PanelContainer] = []
var _index_of: Dictionary = {} var _index_of: Dictionary = {}
# The other names of each soundtrack, by its real name
var _alternatives: Dictionary = {}
@onready var search_panel: Panel = %SearchPanel @onready var search_panel: Panel = %SearchPanel
@onready var search_field: LineEdit = %SearchField @onready var search_field: LineEdit = %SearchField
@@ -54,6 +60,9 @@ func search() -> void:
var row: PanelContainer = _rows[shown[position]] var row: PanelContainer = _rows[shown[position]]
if row.get_index() != position: if row.get_index() != position:
search_results.move_child(row, 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.visible = true
row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven" row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven"
_show_name(row, soundtracks[shown[position]], search_text) _show_name(row, soundtracks[shown[position]], search_text)
@@ -83,19 +92,26 @@ func get_list_of_soundtracks() -> void:
if Settings.is_offline(): if Settings.is_offline():
set_soundtracks(Settings.music_folder.soundtrack_names()) set_soundtracks(Settings.music_folder.soundtrack_names())
return return
var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void: var with_names: Callable = func with_names(list: Array[Soundtrack]) -> void:
if typeof(array) == TYPE_ARRAY: var names: Array[String] = []
set_soundtracks(array) var alternatives: Dictionary = {}
else: for soundtrack: Soundtrack in list:
GameLog.warning("Unexpected data") names.append(soundtrack.name)
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks) alternatives[soundtrack.name] = soundtrack.alternative_names
names.sort_custom(func(a: String, b: String) -> bool:
return a.naturalnocasecmp_to(b) < 0)
set_soundtracks(names, alternatives)
soundtracks_loaded.emit(list)
MusicServerClient.fetch_soundtracks(with_names, _load_names_only)
## Replaces the searchable soundtracks and shows them, keeping the current search. ## Replaces the searchable soundtracks and shows them, keeping the current search.
func set_soundtracks(list: Array) -> void: ## [param alternatives] has the other names of a soundtrack, by its real name.
func set_soundtracks(list: Array, alternatives: Dictionary = {}) -> void:
soundtracks = [] soundtracks = []
soundtracks.append_array(list) soundtracks.append_array(list)
_matcher.set_names(soundtracks) _alternatives = alternatives
_matcher.set_names(soundtracks, alternatives)
_index_of.clear() _index_of.clear()
for index: int in range(soundtracks.size() - 1, -1, -1): for index: int in range(soundtracks.size() - 1, -1, -1):
_index_of[soundtracks[index]] = index _index_of[soundtracks[index]] = index
@@ -142,9 +158,25 @@ func _make_row() -> PanelContainer:
return row return row
# Servers without /soundtracks only list the names
func _load_names_only() -> void:
MusicServerClient.fetch_all_soundtracks_ordered(func(array: Variant) -> void:
if typeof(array) == TYPE_ARRAY:
set_soundtracks(array)
else:
GameLog.warning("Unexpected data"))
func _show_name(row: PanelContainer, soundtrack_name: String, search_text: String) -> void: func _show_name(row: PanelContainer, soundtrack_name: String, search_text: String) -> void:
var text: String = highlighted_name(soundtrack_name, search_text, var highlight: Color = get_theme_color(&"font_color", &"HighlightLarge")
get_theme_color(&"font_color", &"HighlightLarge")) var text: String = highlighted_name(soundtrack_name, search_text, highlight)
# Found by another name: show which, dimmer, after the real name
var alternative: String = _matcher.matched_alternative(soundtrack_name) \
if !search_text.is_empty() else ""
if !alternative.is_empty():
text += " [color=#%s]%s[/color]" % [
get_theme_color(&"default_color", &"SongStatsSecondary").to_html(false),
highlighted_name(alternative, search_text, highlight)]
var label: RichTextLabel = row.get_child(0) var label: RichTextLabel = row.get_child(0)
# Setting the same text again would make the label lay it out again for nothing # Setting the same text again would make the label lay it out again for nothing
if label.text != text: if label.text != text:
+111 -9
View File
@@ -1,12 +1,14 @@
class_name SettingsPopup class_name SettingsPopup
extends BasePopup extends BasePopup
## The application settings: which MusicServer or music folder to play from, fullscreen, ## The application settings: which MusicServer or music folder to play from, the theme,
## how many songs to download ahead, whether to prefer less played soundtracks and whether ## fullscreen, fewer animations, how many songs to download ahead, how random songs are
## plays count in the statistics, and a button that opens the folder the settings are ## picked and whether plays count in the statistics, and a button that opens the folder
## kept in. Opened from the Settings button in the top bar. ## the settings are kept in. Opened from the Settings button in the top bar.
## ##
## A music folder (see [MusicFolder]) is used instead of the server until Use server is ## A music folder (see [MusicFolder]) is used instead of the server until Use server is
## pressed or the game is closed. The settings that need the server are off meanwhile. ## pressed or the game is closed. The settings that need the server are off meanwhile.
## A folder of characters (see [FolderCharacters]) is used the same way; without one, a
## music folder's own [code]characters[/code] folder is used, or else the server's.
## ##
## The quiz settings, such as the winning score, are in [QuizSettingsCard]. ## The quiz settings, such as the winning score, are in [QuizSettingsCard].
@@ -14,16 +16,23 @@ extends BasePopup
## order is changed. A new game starts. ## order is changed. A new game starts.
signal server_changed signal server_changed
## Emitted when a folder of characters is picked or Use server is pressed for them.
signal characters_changed
## Emitted when Clear cache is pressed, for the caches to delete their files.
signal clear_cache_requested
## Space between the rows and the panel's edges. ## Space between the rows and the panel's edges.
const PADDING: Vector2 = Vector2(20, 12) const PADDING: Vector2 = Vector2(20, 12)
@onready var settings_panel: Panel = %SettingsPanel @onready var settings_panel: Panel = %SettingsPanel
@onready var select_server_button: OptionButton = %SelectServerButton @onready var select_server_button: OptionButton = %SelectServerButton
@onready var fullscreen_button: CheckButton = %FullscreenButton @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 cache_label: Label = %CacheLabel
@onready var lower_cache_button: Button = %LowerCacheButton @onready var lower_cache_button: Button = %LowerCacheButton
@onready var increase_cache_button: Button = %IncreaseCacheButton @onready var increase_cache_button: Button = %IncreaseCacheButton
@onready var low_played_button: CheckButton = %LowPlayedCheckButton @onready var random_mode_button: OptionButton = %RandomModeButton
@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton @onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
@onready var rows: VBoxContainer = %VBoxContainer @onready var rows: VBoxContainer = %VBoxContainer
@onready var folder_label: Label = %FolderLabel @onready var folder_label: Label = %FolderLabel
@@ -33,7 +42,14 @@ const PADDING: Vector2 = Vector2(20, 12)
@onready var play_order_row: HBoxContainer = %PlayOrderRow @onready var play_order_row: HBoxContainer = %PlayOrderRow
@onready var play_order_button: OptionButton = %PlayOrderButton @onready var play_order_button: OptionButton = %PlayOrderButton
@onready var folder_dialog: FileDialog = %FolderDialog @onready var folder_dialog: FileDialog = %FolderDialog
@onready var characters_label: Label = %CharactersLabel
@onready var choose_characters_button: Button = %ChooseCharactersButton
@onready var use_server_characters_button: Button = %UseServerCharactersButton
@onready var characters_error_label: Label = %CharactersErrorLabel
@onready var character_folder_dialog: FileDialog = %CharacterFolderDialog
@onready var open_config_folder_button: Button = %OpenConfigFolderButton @onready var open_config_folder_button: Button = %OpenConfigFolderButton
@onready var cache_size_label: Label = %CacheSizeLabel
@onready var clear_cache_button: Button = %ClearCacheButton
## Shows a folder in the system's file manager. The tests replace it, so no file manager ## Shows a folder in the system's file manager. The tests replace it, so no file manager
## opens while they run. ## opens while they run.
@@ -46,22 +62,32 @@ var _panel_middle_y: float = 0.0
func _ready() -> void: func _ready() -> void:
for server: String in Settings.servers: for server: String in Settings.servers:
select_server_button.add_item(server) select_server_button.add_item(server)
for entry: Dictionary in Settings.THEMES:
theme_button.add_item(entry.name)
theme_button.select(Settings.THEMES.find(Settings.theme_entry(Settings.theme_id)))
theme_button.item_selected.connect(select_theme)
fullscreen_button.button_pressed = Settings.fullscreen fullscreen_button.button_pressed = Settings.fullscreen
select_server_button.select(Settings.selected_server) select_server_button.select(Settings.selected_server)
cache_label.text = str(Playlist.number_of_tracks_to_preload) cache_label.text = str(Playlist.number_of_tracks_to_preload)
fullscreen_button.pressed.connect(fullscreen) 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) choose_folder_button.pressed.connect(folder_dialog.popup_centered)
folder_dialog.dir_selected.connect(use_music_folder) folder_dialog.dir_selected.connect(use_music_folder)
use_server_button.pressed.connect(use_server) use_server_button.pressed.connect(use_server)
choose_characters_button.pressed.connect(character_folder_dialog.popup_centered)
character_folder_dialog.dir_selected.connect(use_character_folder)
use_server_characters_button.pressed.connect(use_server_characters)
open_config_folder_button.pressed.connect(open_config_folder) open_config_folder_button.pressed.connect(open_config_folder)
open_config_folder_button.tooltip_text = Settings.config_dir() open_config_folder_button.tooltip_text = Settings.config_dir()
clear_cache_button.pressed.connect(clear_cache)
play_order_button.item_selected.connect(_on_play_order_selected) play_order_button.item_selected.connect(_on_play_order_selected)
lower_cache_button.pressed.connect(lower_cache) lower_cache_button.pressed.connect(lower_cache)
increase_cache_button.pressed.connect(increase_cache) increase_cache_button.pressed.connect(increase_cache)
select_server_button.item_selected.connect(select_server) select_server_button.item_selected.connect(select_server)
low_played_button.toggled.connect( random_mode_button.item_selected.connect(
func(on: bool) -> void: Settings.use_low_played_mode = on) func(mode: int) -> void: Settings.random_mode = mode as Settings.RandomMode)
add_to_database_button.toggled.connect(func(on: bool) -> void: Settings.add_to_stats = on) add_to_database_button.toggled.connect(func(on: bool) -> void: Settings.add_to_stats = on)
visibility_changed.connect(_on_visibility_changed) visibility_changed.connect(_on_visibility_changed)
# Alt+Enter switches fullscreen from the main window, which resizes the window # Alt+Enter switches fullscreen from the main window, which resizes the window
@@ -70,6 +96,17 @@ func _ready() -> void:
_show_music_source() _show_music_source()
## Switches to the theme at [param index] in [constant Settings.THEMES] at once.
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: func fullscreen() -> void:
Settings.set_fullscreen(!Settings.fullscreen) Settings.set_fullscreen(!Settings.fullscreen)
@@ -104,6 +141,60 @@ func open_config_folder() -> void:
show_folder.call(dir) show_folder.call(dir)
## Deletes the downloaded character pictures, covers and songs, except the songs the
## quiz uses, and shows how much space that freed.
func clear_cache() -> void:
var freed: int = cache_size()
clear_cache_requested.emit()
Playlist.clear_song_files(Playlist.song_files_in_use())
cache_size_label.text = "Cleared " + size_text(freed)
clear_cache_button.disabled = true
## Shows how much space the downloaded files take.
func show_cache_size() -> void:
var size: int = cache_size()
cache_size_label.text = size_text(size)
clear_cache_button.disabled = size == 0
## How many bytes the downloaded character pictures, covers and songs take, leaving out
## the songs the quiz uses, which clearing keeps.
static func cache_size() -> int:
return FileCache.folder_size(CharacterCache.folder()) \
+ FileCache.folder_size(CoverCache.folder()) \
+ FileCache.folder_size(Playlist.songs_folder(), Playlist.song_files_in_use())
## [param bytes] for people, e.g. "350 KB" or "14.2 MB".
static func size_text(bytes: int) -> String:
if bytes == 0:
return "Nothing"
if bytes < 1 << 20:
return "%d KB" % ceili(bytes / 1024.0)
return "%.1f MB" % (bytes / float(1 << 20))
## Reads the characters from the folder at [param path] instead of the server. A folder
## without pictures is not used, and the popup says so.
func use_character_folder(path: String) -> void:
if FolderCharacters.picture_names(path).is_empty():
characters_error_label.text = "No pictures found in " + path.trim_suffix("/").get_file()
characters_error_label.visible = true
_fit_to_rows()
return
Settings.character_folder = path.trim_suffix("/")
_show_music_source()
characters_changed.emit()
## Uses the server's characters again, or the music folder's own if it has them.
func use_server_characters() -> void:
Settings.character_folder = ""
_show_music_source()
characters_changed.emit()
func lower_cache() -> void: func lower_cache() -> void:
Playlist.number_of_tracks_to_preload = maxi(Playlist.number_of_tracks_to_preload - 1, 1) Playlist.number_of_tracks_to_preload = maxi(Playlist.number_of_tracks_to_preload - 1, 1)
cache_label.text = str(Playlist.number_of_tracks_to_preload) cache_label.text = str(Playlist.number_of_tracks_to_preload)
@@ -137,10 +228,12 @@ func _use_server(new_server: int) -> void:
func _on_visibility_changed() -> void: func _on_visibility_changed() -> void:
if visible: if visible:
_update_fullscreen_button() _update_fullscreen_button()
low_played_button.set_pressed_no_signal(Settings.use_low_played_mode) show_cache_size()
random_mode_button.select(Settings.random_mode)
add_to_database_button.set_pressed_no_signal(Settings.add_to_stats) add_to_database_button.set_pressed_no_signal(Settings.add_to_stats)
else: else:
folder_error_label.visible = false folder_error_label.visible = false
characters_error_label.visible = false
_fit_to_rows() _fit_to_rows()
@@ -162,7 +255,16 @@ func _show_music_source() -> void:
folder_label.text = folder.display_name() folder_label.text = folder.display_name()
folder_label.tooltip_text = folder.path folder_label.tooltip_text = folder.path
play_order_row.visible = folder != null && folder.kind == MusicFolder.Kind.PREPARED_QUIZ play_order_row.visible = folder != null && folder.kind == MusicFolder.Kind.PREPARED_QUIZ
for control: BaseButton in [select_server_button, low_played_button, add_to_database_button]: characters_error_label.visible = false
use_server_characters_button.visible = !Settings.character_folder.is_empty()
characters_label.tooltip_text = Settings.characters_dir()
if !Settings.character_folder.is_empty():
characters_label.text = Settings.character_folder.get_file()
elif !Settings.characters_dir().is_empty():
characters_label.text = "From " + folder.display_name()
else:
characters_label.text = "From the server"
for control: BaseButton in [select_server_button, random_mode_button, add_to_database_button]:
control.disabled = folder != null control.disabled = folder != null
_fit_to_rows() _fit_to_rows()
+288 -10
View File
@@ -1,27 +1,305 @@
class_name SoundtrackStatsPopup class_name SoundtrackStatsPopup
extends BasePopup extends BasePopup
## Shows a soundtrack's play statistics and every song in it, most played first. ## Shows a soundtrack's cover, play statistics and alternative names, and every song in
## Opened by clicking a card in the statistics view. ## it, most played first. Opened by clicking a card in the statistics view.
##
## Edit names opens an editor for the alternative names in place of the song list. Saving
## sends them to the server, which search then finds the soundtrack by.
##
## Change cover opens the cover menu there instead: upload a picture, pick a game's or
## anime's cover by its ID on SteamGridDB or AniList, or remove the cover so the server
## looks for one again.
## Emitted when the alternative names of [param soundtrack] have been saved.
signal alternative_names_changed(soundtrack: Soundtrack)
## Emitted when the cover of the soundtrack [param uuid] changed, with its new cover time
## [param modified], 0 when it was removed.
signal cover_changed(uuid: String, modified: int)
## The largest picture the server takes as a cover.
const MAX_COVER_BYTES: int = 10 << 20
## The covers on disk, shared with the statistics view. No cover is shown without it.
var covers: CoverCache
## The soundtrack shown.
var soundtrack: SoundtrackWithSongs
# The names in the editor, saved only when Save is pressed
var _edited_names: Array[String] = []
@onready var title_label: Label = %TitleLabel @onready var title_label: Label = %TitleLabel
@onready var summary_label: Label = %SummaryLabel @onready var summary_label: Label = %SummaryLabel
@onready var cover_picture: TextureRect = %CoverPicture
@onready var cover_placeholder: Label = %CoverPlaceholder
@onready var names_label: Label = %NamesLabel
@onready var edit_names_button: Button = %EditNamesButton
@onready var song_scroll: ScrollContainer = %ScrollContainer
@onready var song_list: VBoxContainer = %SongList @onready var song_list: VBoxContainer = %SongList
@onready var names_editor: VBoxContainer = %NamesEditor
@onready var editor_names: VBoxContainer = %EditorNames
@onready var new_name_field: LineEdit = %NewNameField
@onready var add_name_button: Button = %AddNameButton
@onready var editor_status: Label = %EditorStatus
@onready var save_names_button: Button = %SaveNamesButton
@onready var cancel_names_button: Button = %CancelNamesButton
@onready var change_cover_button: Button = %ChangeCoverButton
@onready var cover_editor: VBoxContainer = %CoverEditor
@onready var choose_picture_button: Button = %ChoosePictureButton
@onready var remove_cover_button: Button = %RemoveCoverButton
@onready var cover_source_button: OptionButton = %CoverSourceButton
@onready var cover_id_field: LineEdit = %CoverIdField
@onready var use_source_button: Button = %UseSourceButton
@onready var cover_status: Label = %CoverStatus
@onready var close_cover_button: Button = %CloseCoverButton
@onready var picture_dialog: FileDialog = %PictureDialog
## Shows [param soundtrack] and opens the popup. func _ready() -> void:
func show_soundtrack(soundtrack: SoundtrackWithSongs) -> void: edit_names_button.pressed.connect(edit_names)
title_label.text = soundtrack.soundtrack_name add_name_button.pressed.connect(add_name)
var summary: PackedStringArray = [SoundtrackCard.played_text(soundtrack.soundtrack_played)] new_name_field.text_submitted.connect(func(_text: String) -> void: add_name())
var last_played: String = SoundtrackCard.last_played_text(soundtrack.soundtrack_last_played) save_names_button.pressed.connect(save_names)
cancel_names_button.pressed.connect(stop_editing)
change_cover_button.pressed.connect(change_cover)
choose_picture_button.pressed.connect(picture_dialog.popup_centered)
picture_dialog.file_selected.connect(upload_picture)
remove_cover_button.pressed.connect(remove_cover)
use_source_button.pressed.connect(use_cover_source)
cover_id_field.text_submitted.connect(func(_text: String) -> void: use_cover_source())
close_cover_button.pressed.connect(stop_editing)
## Shows [param shown] and opens the popup.
func show_soundtrack(shown: SoundtrackWithSongs) -> void:
soundtrack = shown
stop_editing()
title_label.text = shown.soundtrack_name
var summary: PackedStringArray = [SoundtrackCard.played_text(shown.soundtrack_played)]
var last_played: String = SoundtrackCard.last_played_text(shown.soundtrack_last_played)
if !last_played.is_empty(): if !last_played.is_empty():
summary.append(last_played) summary.append(last_played)
summary.append("%d %s" % [soundtrack.songs.size(), summary.append("%d %s" % [shown.songs.size(),
"song" if soundtrack.songs.size() == 1 else "songs"]) "song" if shown.songs.size() == 1 else "songs"])
summary_label.text = " · ".join(summary) summary_label.text = " · ".join(summary)
_show_names()
cover_placeholder.text = SoundtrackCard.initials(shown.soundtrack_name)
_load_cover()
Settings.delete_children(song_list) Settings.delete_children(song_list)
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(soundtrack.songs) var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(shown.songs)
for row: int in songs.size(): for row: int in songs.size():
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row, song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
&"TextLarge")) &"TextLarge"))
open() open()
## Opens the editor for the alternative names, in place of the song list.
func edit_names() -> void:
_edited_names.assign(soundtrack.alternative_names)
new_name_field.text = ""
editor_status.visible = false
_show_edited_names()
_show_editor(names_editor)
new_name_field.grab_focus()
## Opens the cover menu, in place of the song list.
func change_cover() -> void:
cover_id_field.text = ""
cover_status.visible = false
_set_cover_busy(false)
_show_editor(cover_editor)
## Closes the editors, without saving the names.
func stop_editing() -> void:
_show_editor(null)
_set_saving(false)
_set_cover_busy(false)
## Uploads the picture at [param path] as the soundtrack's cover.
func upload_picture(path: String) -> void:
var picture: PackedByteArray = FileAccess.get_file_as_bytes(path)
var content_type: String = picture_type(picture)
if picture.size() > MAX_COVER_BYTES:
_show_cover_status("The picture is larger than %d MB." % (MAX_COVER_BYTES >> 20))
return
if content_type.is_empty() || CharacterCard.image_from_bytes(picture) == null:
_show_cover_status("Only JPEG, PNG and WebP pictures can be used.")
return
_set_cover_busy(true, "Uploading the picture…")
MusicServerClient.upload_soundtrack_cover(soundtrack.soundtrack_uuid, picture,
content_type, _on_cover_saved.bind("The new cover is saved."), _on_cover_not_saved)
## Makes the cover of the game or anime with the ID in the field, on the site picked,
## the soundtrack's cover.
func use_cover_source() -> void:
var id: String = cover_id_field.text.strip_edges()
if id.is_empty() || use_source_button.disabled:
return
var source: String = MusicServerClient.COVER_SOURCES[cover_source_button.selected]
_set_cover_busy(true, "Getting the cover from %s…"
% cover_source_button.get_item_text(cover_source_button.selected))
MusicServerClient.set_soundtrack_cover_source(soundtrack.soundtrack_uuid, source, id,
_on_cover_saved.bind("The new cover is saved."), _on_cover_not_saved)
## Removes the soundtrack's cover, so the server looks for one again.
func remove_cover() -> void:
_set_cover_busy(true, "Removing the cover…")
MusicServerClient.delete_soundtrack_cover(soundtrack.soundtrack_uuid,
_on_cover_saved.bind("The cover is removed. The server will look for a new one."),
_on_cover_not_saved)
## The type of [param picture] to upload it as, e.g. [code]"image/png"[/code], or empty
## when it is not a JPEG, PNG or WebP picture.
static func picture_type(picture: PackedByteArray) -> String:
if picture.size() >= 4 && picture.slice(0, 4) == PackedByteArray([0x89, 0x50, 0x4E, 0x47]):
return "image/png"
if picture.size() >= 3 && picture.slice(0, 3) == PackedByteArray([0xFF, 0xD8, 0xFF]):
return "image/jpeg"
if picture.size() >= 12 && picture.slice(8, 12).get_string_from_ascii() == "WEBP":
return "image/webp"
return ""
## Adds the name in the field to the edited names, unless it is empty, the soundtrack's
## own name or already there.
func add_name() -> void:
var new_name: String = new_name_field.text.strip_edges()
if new_name.is_empty() || new_name.to_lower() == soundtrack.soundtrack_name.to_lower() \
|| _edited_names.any(func(name: String) -> bool:
return name.to_lower() == new_name.to_lower()):
return
_edited_names.append(new_name)
new_name_field.text = ""
_show_edited_names()
## Takes [param removed] out of the edited names.
func remove_name(removed: String) -> void:
_edited_names.erase(removed)
_show_edited_names()
## Sends the edited names to the server. The editor closes when they are saved.
func save_names() -> void:
# A name typed but not added yet is meant to be saved too
add_name()
_set_saving(true)
MusicServerClient.set_alternative_names(soundtrack.soundtrack_uuid, _edited_names,
_on_names_saved, _on_names_not_saved)
func _on_names_saved(updated: Soundtrack) -> void:
soundtrack.alternative_names = updated.alternative_names
_show_names()
stop_editing()
alternative_names_changed.emit(updated)
func _on_names_not_saved() -> void:
_set_saving(false)
editor_status.text = "Could not save the names. Try again."
editor_status.visible = true
func _set_saving(saving: bool) -> void:
save_names_button.disabled = saving
cancel_names_button.disabled = saving
save_names_button.text = "Saving…" if saving else "Save"
func _show_names() -> void:
var names: Array[String] = soundtrack.alternative_names
names_label.text = ", ".join(names) if !names.is_empty() else "No other names yet"
func _show_edited_names() -> void:
Settings.delete_children(editor_names)
for row: int in _edited_names.size():
var line: HBoxContainer = HBoxContainer.new()
var label: Label = Label.new()
label.text = _edited_names[row]
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
line.add_child(label)
var remove: Button = Button.new()
remove.text = "Remove"
remove.focus_mode = Control.FOCUS_NONE
remove.pressed.connect(remove_name.bind(_edited_names[row]))
line.add_child(remove)
var list_row: PanelContainer = PanelContainer.new()
list_row.theme_type_variation = &"ListRowOdd" if row % 2 != 0 else &"ListRowEven"
list_row.add_child(line)
editor_names.add_child(list_row)
# Shows [param editor] in place of the song list, or the song list when it is null
func _show_editor(editor: Control) -> void:
names_editor.visible = editor == names_editor
cover_editor.visible = editor == cover_editor
song_scroll.visible = editor == null
edit_names_button.disabled = editor != null
change_cover_button.disabled = editor != null
# The cover changed on the server; its new time tells whether it has one and fetches it
func _on_cover_saved(message: String) -> void:
var uuid: String = soundtrack.soundtrack_uuid
var with_time: Callable = func with_time(modified: int) -> void:
if soundtrack == null || soundtrack.soundtrack_uuid != uuid:
return
soundtrack.cover_modified = modified
_load_cover()
_set_cover_busy(false)
_show_cover_status(message)
cover_changed.emit(uuid, modified)
if covers != null:
covers.refresh(uuid, with_time)
else:
with_time.call(soundtrack.cover_modified)
func _on_cover_not_saved() -> void:
_set_cover_busy(false)
var reason: String = MusicServerClient.last_error
_show_cover_status("Could not change the cover: %s."
% (reason if !reason.is_empty() else "the server could not be reached"))
func _set_cover_busy(busy: bool, message: String = "") -> void:
for button: Button in [choose_picture_button, use_source_button, close_cover_button]:
button.disabled = busy
remove_cover_button.disabled = busy || soundtrack == null \
|| soundtrack.cover_modified <= 0
cover_source_button.disabled = busy
if busy:
_show_cover_status(message)
func _show_cover_status(message: String) -> void:
cover_status.text = message
cover_status.visible = true
func _load_cover() -> void:
_show_cover(null)
if covers != null:
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified,
_show_cover_of.bind(soundtrack.soundtrack_uuid))
func _show_cover(cover: Image) -> void:
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
cover_placeholder.visible = cover == null
# Covers arrive later; one for a soundtrack no longer shown is left out
func _show_cover_of(cover: Image, uuid: String) -> void:
if soundtrack != null && soundtrack.soundtrack_uuid == uuid:
_show_cover(cover)
+16 -4
View File
@@ -17,7 +17,7 @@ var has_synced: bool = false
@onready var no_changes_label: Label = %NoChangesLabel @onready var no_changes_label: Label = %NoChangesLabel
@onready var progress_hbox: HBoxContainer = %ProgressHBoxContainer @onready var progress_hbox: HBoxContainer = %ProgressHBoxContainer
@onready var sync_hbox: HBoxContainer = %SyncHBoxContainer @onready var sync_hbox: HBoxContainer = %SyncHBoxContainer
@onready var sync_progress_bar: ProgressBar = %SyncProgressBar @onready var sync_progress_bar: AnimatedProgressBar = %SyncProgressBar
@onready var soundtracks_added_vbox: VBoxContainer = %SoundtracksAddedVBox @onready var soundtracks_added_vbox: VBoxContainer = %SoundtracksAddedVBox
@onready var soundtracks_added_separator: VSeparator = %SoundtracksAddedSeparator @onready var soundtracks_added_separator: VSeparator = %SoundtracksAddedSeparator
@onready var soundtracks_readded_vbox: VBoxContainer = %SoundtracksReAddedVBox @onready var soundtracks_readded_vbox: VBoxContainer = %SoundtracksReAddedVBox
@@ -56,7 +56,7 @@ func show_section_if_has_data(data: Variant, vbox: VBoxContainer, separator: VSe
func start_sync() -> void: func start_sync() -> void:
GameLog.debug("start_sync") GameLog.debug("start_sync")
visible = true open()
if has_synced: if has_synced:
# Already synced, just show the results # Already synced, just show the results
show_sync_result() show_sync_result()
@@ -77,13 +77,24 @@ func _on_sync_timeout() -> void:
GameLog.debug("data_received: ", data_received) GameLog.debug("data_received: ", data_received)
if data_received != null: if data_received != null:
if data_received.has("progress"): if data_received.has("progress"):
sync_progress_bar.value = int(data_received.progress) sync_progress_bar.move_to(int(data_received.progress))
sync_time.text = data_received.time_spent if data_received.has("time_spent") else "" sync_time.text = data_received.time_spent if data_received.has("time_spent") else ""
else: else:
on_sync_finished(data_received) _finish_progress(data_received)
MusicServerClient.fetch_sync_progress(get_progress) MusicServerClient.fetch_sync_progress(get_progress)
# Fills the bar and lets it pop before the result takes its place
func _finish_progress(data_received: Dictionary) -> void:
sync_timer.stop()
if Anim.enabled() && progress_hbox.visible:
sync_progress_bar.move_to(sync_progress_bar.max_value)
await get_tree().create_timer(Anim.SLOW).timeout
Anim.pop(sync_progress_bar, 1.04, Anim.SLOW)
await get_tree().create_timer(Anim.SLOW).timeout
on_sync_finished(data_received)
func on_sync_finished(data_received: Dictionary) -> void: func on_sync_finished(data_received: Dictionary) -> void:
has_synced = true has_synced = true
reset_sync_result() reset_sync_result()
@@ -218,6 +229,7 @@ func show_sync_result() -> void:
func show_sync_progress() -> void: func show_sync_progress() -> void:
sync_title_label.text = "Sync in Progress" sync_title_label.text = "Sync in Progress"
progress_hbox.visible = true progress_hbox.visible = true
sync_progress_bar.wait()
no_changes_label.visible = false no_changes_label.visible = false
sync_hbox.visible = false sync_hbox.visible = false
caught_errors_vbox.visible = false caught_errors_vbox.visible = false
+156
View File
@@ -0,0 +1,156 @@
class_name UpdatePopup
extends BasePopup
## Gets the newest version of the game, opened from "Version X is available" in the top
## bar.
##
## Update and restart downloads the new game, puts it in place of this one and starts it;
## the quiz is saved first, so it continues where it was. Where the game can't update
## itself, e.g. when it runs from the editor, only Download in browser is offered, which
## opens the download (or, on other platforms, the release page) in the browser.
## Emitted just before the game quits to start the new version, so the quiz can be saved.
signal restarting
## Space between the rows and the panel's edges.
const PADDING: Vector2 = Vector2(24, 16)
## Opens a link in the browser. The tests replace it, so no browser opens.
var open_url: Callable = OS.shell_open
## The version offered.
var latest_version: String = ""
var _downloading: bool = false
# Where the panel's middle is, so it stays centred as it grows and shrinks
var _panel_middle_y: float = 0.0
@onready var title_label: Label = %TitleLabel
@onready var info_label: Label = %InfoLabel
@onready var reason_label: Label = %ReasonLabel
@onready var download_progress: ProgressBar = %DownloadProgress
@onready var status_label: Label = %StatusLabel
@onready var not_now_button: Button = %NotNowButton
@onready var browser_button: Button = %BrowserButton
@onready var update_now_button: Button = %UpdateNowButton
@onready var updater: Updater = %Updater
@onready var rows: VBoxContainer = %Rows
func _ready() -> void:
not_now_button.pressed.connect(close)
browser_button.pressed.connect(download_in_browser)
update_now_button.pressed.connect(update_now)
_panel_middle_y = panel.position.y + panel.size.y / 2
## Offers version [param latest] and opens the popup.
func show_update(latest: String) -> void:
latest_version = latest
title_label.text = "Version %s is available" % latest
info_label.text = "You have %s. The game restarts with the new version, and the quiz " \
% Settings.version + "continues where it was."
var reason: String = updater.can_update_in_place()
update_now_button.visible = reason.is_empty()
reason_label.visible = !reason.is_empty()
reason_label.text = reason + " Download it in the browser instead."
info_label.visible = reason.is_empty()
download_progress.visible = false
status_label.visible = false
_set_busy(false)
_fit_to_rows()
open()
## Stops a download that is under way when the popup is closed.
func close() -> void:
if _downloading:
updater.cancel()
_downloading = false
_set_busy(false)
super()
## Downloads the new version, puts it in place and restarts the game.
func update_now() -> void:
_set_busy(true)
_show_status("Getting the download link…")
MusicServerClient.fetch_client_download_url(Updater.asset_for(updater.os_name, true),
_download, _on_failed.bind("The server has no download for this version."))
## Opens the new version's download in the browser, or the release page when there is
## none for this platform.
func download_in_browser() -> void:
var asset: String = Updater.asset_for(updater.os_name, false)
if asset.is_empty():
open_url.call(Updater.RELEASES_PAGE)
return
_show_status("Opening the download in the browser…")
MusicServerClient.fetch_client_download_url(asset,
func(url: String) -> void: open_url.call(url),
func() -> void: open_url.call(Updater.RELEASES_PAGE))
func _download(url: String) -> void:
_downloading = true
download_progress.value = 0.0
download_progress.visible = true
_show_status("Downloading…")
_fit_to_rows()
updater.download(url, _on_progress, _install, _on_failed)
func _on_progress(done: int, total: int) -> void:
if total > 0:
download_progress.value = float(done) / float(total)
status_label.text = "Downloading… %s of %s" % [SettingsPopup.size_text(done),
SettingsPopup.size_text(total)]
func _install(path: String) -> void:
_downloading = false
_show_status("Installing…")
var error: Error = Updater.install(path, updater.executable_path, updater.os_name)
if error != OK:
DirAccess.remove_absolute(path)
_on_failed("The game could not be replaced: %s." % error_string(error))
return
_show_status("Restarting…")
restarting.emit()
updater.restart()
func _on_failed(reason: String) -> void:
_downloading = false
download_progress.visible = false
_set_busy(false)
_show_status(reason)
_fit_to_rows()
func _set_busy(busy: bool) -> void:
update_now_button.disabled = busy
browser_button.disabled = busy
# Closing while busy stops the download
not_now_button.text = "Cancel" if busy else "Not now"
func _show_status(message: String) -> void:
status_label.text = message
if !status_label.visible:
status_label.visible = true
_fit_to_rows()
# Makes the panel as tall as its rows, around the same middle
func _fit_to_rows() -> void:
# A popup that was never shown hasn't laid out its rows, and wrapped text measures
# its height from its width
var width: float = panel.size.x - PADDING.x * 2
for label: Label in [info_label, reason_label, status_label]:
label.size = Vector2(width, 0)
label.update_minimum_size()
var height: float = rows.get_combined_minimum_size().y + PADDING.y * 2
panel.size.y = height
panel.position.y = floorf(_panel_middle_y - height / 2)
rows.position = PADDING
rows.size = Vector2(panel.size.x - PADDING.x * 2, height - PADDING.y * 2)
+1
View File
@@ -0,0 +1 @@
uid://dupwb11ovvn7j
+52 -1
View File
@@ -2,7 +2,12 @@ class_name WinnerPopup
extends BasePopup extends BasePopup
## Shows who won, with their character, where the other players placed, and how the game ## 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. ## 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. ## Size of the coloured square in front of each name in the legend.
const LEGEND_SQUARE_SIZE: Vector2 = Vector2(14, 14) 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. ## 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. ## Tied players get the same placing. With no players, it says the quiz is over.
func show_winners(winners: Array[PlayerObject]) -> void: func show_winners(winners: Array[PlayerObject]) -> void:
visible = true open()
winner_label.text = winners_text(winners) winner_label.text = winners_text(winners)
_show_pictures(winners) _show_pictures(winners)
@@ -49,6 +54,7 @@ func show_winners(winners: Array[PlayerObject]) -> void:
players_list.add_child(label) players_list.add_child(label)
show_history(ScoreHistory.from_playlist(Settings.player_array)) show_history(ScoreHistory.from_playlist(Settings.player_array))
_celebrate()
## The headline for [param winners], e.g. "Crono won with 5 points!!" or "Crono and ## 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 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 # One picture per winner, next to each other
func _show_pictures(winners: Array[PlayerObject]) -> void: func _show_pictures(winners: Array[PlayerObject]) -> void:
var header: Node = winner_picture.get_parent() var header: Node = winner_picture.get_parent()
+38 -3
View File
@@ -2,22 +2,57 @@
[ext_resource type="Script" path="res://quiz/now_playing.gd" id="part_script"] [ext_resource type="Script" path="res://quiz/now_playing.gd" id="part_script"]
[node name="NowPlaying" type="VBoxContainer" unique_id=42468498] [node name="NowPlaying" type="HBoxContainer" unique_id=42468498]
layout_mode = 3 layout_mode = 3
offset_right = 1088.0 offset_right = 1088.0
offset_bottom = 320.0 offset_bottom = 320.0
theme_override_constants/separation = 32
alignment = 1
script = ExtResource("part_script") script = ExtResource("part_script")
[node name="SoundtrackLabel" type="Label" parent="." unique_id=173147793] [node name="CoverFrame" type="Panel" parent="."]
unique_name_in_owner = true
visible = false
clip_children = 2
custom_minimum_size = Vector2(180, 270)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
[node name="CoverPicture" type="TextureRect" parent="CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Titles" type="VBoxContainer" parent="."]
unique_name_in_owner = true
layout_mode = 2
alignment = 1
[node name="SoundtrackLabel" type="Label" parent="Titles" unique_id=173147793]
unique_name_in_owner = true
custom_minimum_size = Vector2(1, 1) custom_minimum_size = Vector2(1, 1)
layout_mode = 2 layout_mode = 2
theme_type_variation = &"TitleLarge" theme_type_variation = &"TitleLarge"
horizontal_alignment = 1 horizontal_alignment = 1
autowrap_mode = 2 autowrap_mode = 2
text_overrun_behavior = 3
max_lines_visible = 2
[node name="SongLabel" type="Label" parent="." unique_id=1687490027] [node name="SongLabel" type="Label" parent="Titles" unique_id=1687490027]
unique_name_in_owner = true
custom_minimum_size = Vector2(1, 1) custom_minimum_size = Vector2(1, 1)
layout_mode = 2 layout_mode = 2
theme_type_variation = &"TitleMedium" theme_type_variation = &"TitleMedium"
horizontal_alignment = 1 horizontal_alignment = 1
autowrap_mode = 2 autowrap_mode = 2
text_overrun_behavior = 3
max_lines_visible = 2
+47 -48
View File
@@ -4,90 +4,89 @@
[ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="2_hpj3s"] [ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="2_hpj3s"]
[ext_resource type="Texture2D" uid="uid://b2kj6m8qpsgb1" path="res://icons/person_remove_light.svg" id="3_j3uxe"] [ext_resource type="Texture2D" uid="uid://b2kj6m8qpsgb1" path="res://icons/person_remove_light.svg" id="3_j3uxe"]
[node name="PlayerRow" type="Control"] [node name="PlayerRow" type="PanelContainer"]
custom_minimum_size = Vector2(2.08165e-12, 40) custom_minimum_size = Vector2(0, 40)
layout_mode = 3
anchors_preset = 0
offset_bottom = 40.0
size_flags_horizontal = 3 size_flags_horizontal = 3
theme_type_variation = &"ListRowEven"
script = ExtResource("1_if4kc") script = ExtResource("1_if4kc")
[node name="HBoxContainer" type="HBoxContainer" parent="."] [node name="HBoxContainer" type="HBoxContainer" parent="."]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="Panel4" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(5, 40)
layout_mode = 2 layout_mode = 2
theme_type_variation = &"DarkSpacer" theme_override_constants/separation = 8
[node name="Name" type="Label" parent="HBoxContainer"] [node name="CharacterFrame" type="PanelContainer" parent="HBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(130, 40)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_vertical = 4
size_flags_vertical = 1 theme_type_variation = &"PlayerCharacter"
text = ": 0"
vertical_alignment = 1
clip_text = true
text_overrun_behavior = 3
[node name="Panel3" type="Panel" parent="HBoxContainer"] [node name="Character" type="TextureButton" parent="HBoxContainer/CharacterFrame"]
custom_minimum_size = Vector2(16, 2.08165e-12)
layout_mode = 2
theme_type_variation = &"DarkSpacer"
[node name="Character" type="TextureButton" parent="HBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(80, 40) custom_minimum_size = Vector2(64, 32)
layout_mode = 2 layout_mode = 2
tooltip_text = "Change character"
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
texture_normal = ExtResource("2_hpj3s") texture_normal = ExtResource("2_hpj3s")
ignore_texture_size = true ignore_texture_size = true
stretch_mode = 5 stretch_mode = 5
[node name="Panel2" type="Panel" parent="HBoxContainer"] [node name="Name" type="Label" parent="HBoxContainer"]
custom_minimum_size = Vector2(30, 40) unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
theme_type_variation = &"DarkSpacer" size_flags_horizontal = 3
mouse_filter = 1
theme_type_variation = &"TextMedium"
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)
layout_mode = 2
theme_type_variation = &"PlayerScore"
text = "0"
horizontal_alignment = 2
vertical_alignment = 1
[node name="AddPoint" type="Button" parent="HBoxContainer"] [node name="AddPoint" type="Button" parent="HBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(40, 40) custom_minimum_size = Vector2(40, 36)
layout_mode = 2 layout_mode = 2
size_flags_vertical = 4
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "+1" text = "+1"
[node name="Panel5" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(3, 40)
layout_mode = 2
theme_type_variation = &"DarkSpacer"
[node name="RemovePoint" type="Button" parent="HBoxContainer"] [node name="RemovePoint" type="Button" parent="HBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(40, 40) custom_minimum_size = Vector2(40, 36)
layout_mode = 2 layout_mode = 2
size_flags_vertical = 4
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "-1" text = "-1"
[node name="Panel" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(30, 40)
layout_mode = 2
theme_type_variation = &"DarkSpacer"
[node name="RemovePlayer" type="TextureButton" parent="HBoxContainer"] [node name="RemovePlayer" type="TextureButton" parent="HBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(40, 40) visible = false
custom_minimum_size = Vector2(36, 36)
layout_mode = 2 layout_mode = 2
size_flags_vertical = 4
tooltip_text = "Remove player"
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
texture_normal = ExtResource("3_j3uxe") texture_normal = ExtResource("3_j3uxe")
stretch_mode = 0 ignore_texture_size = true
stretch_mode = 5
+27 -18
View File
@@ -2,7 +2,6 @@
[ext_resource type="Script" path="res://quiz/players_panel.gd" id="part_script"] [ext_resource type="Script" path="res://quiz/players_panel.gd" id="part_script"]
[ext_resource type="Texture2D" uid="uid://bcfmpd7h512ef" path="res://icons/person_add_light.svg" id="5_31tjv"] [ext_resource type="Texture2D" uid="uid://bcfmpd7h512ef" path="res://icons/person_add_light.svg" id="5_31tjv"]
[ext_resource type="Script" uid="uid://cgakje8gb37tf" path="res://quiz/player_name_field.gd" id="7_qsdfy"]
[sub_resource type="InputEventKey" id="InputEventKey_03bm3"] [sub_resource type="InputEventKey" id="InputEventKey_03bm3"]
device = -1 device = -1
@@ -17,6 +16,7 @@ events = [SubResource("InputEventKey_03bm3")]
layout_mode = 3 layout_mode = 3
offset_right = 641.0 offset_right = 641.0
offset_bottom = 401.0 offset_bottom = 401.0
theme_type_variation = &"PlayerList"
script = ExtResource("part_script") script = ExtResource("part_script")
[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=270084211] [node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=270084211]
@@ -26,55 +26,63 @@ layout_mode = 2
custom_minimum_size = Vector2(2.08165e-12, 40) custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2 layout_mode = 2
[node name="HeaderBalance" type="Control" parent="VBoxContainer/HBoxContainer"]
custom_minimum_size = Vector2(36, 0)
layout_mode = 2
mouse_filter = 2
[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=1331357386] [node name="Label" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=1331357386]
custom_minimum_size = Vector2(2.08165e-12, 40) custom_minimum_size = Vector2(0, 40)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 1 size_flags_vertical = 1
theme_type_variation = &"TextLarge"
text = "Players" text = "Players"
horizontal_alignment = 1 horizontal_alignment = 1
vertical_alignment = 1 vertical_alignment = 1
[node name="AddPlayersButton" type="TextureButton" parent="VBoxContainer/HBoxContainer" unique_id=634410148] [node name="AddPlayersButton" type="TextureButton" parent="VBoxContainer/HBoxContainer" unique_id=634410148]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(2.08165e-12, 40) custom_minimum_size = Vector2(36, 36)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 8 size_flags_horizontal = 8
size_flags_vertical = 4
tooltip_text = "Add or remove players (Alt+A)"
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
shortcut = SubResource("Shortcut_jafqj") shortcut = SubResource("Shortcut_jafqj")
texture_normal = ExtResource("5_31tjv") texture_normal = ExtResource("5_31tjv")
ignore_texture_size = true
stretch_mode = 5
[node name="AddPlayerContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=1371948739] [node name="AddPlayerContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=1371948739]
unique_name_in_owner = true unique_name_in_owner = true
visible = false visible = false
custom_minimum_size = Vector2(2.08165e-12, 40) custom_minimum_size = Vector2(0, 36)
layout_mode = 2 layout_mode = 2
theme_override_constants/separation = 8
alignment = 1
[node name="Panel" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=1569854056] [node name="PlayerNameField" type="LineEdit" parent="VBoxContainer/AddPlayerContainer" unique_id=332535327]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"DarkSpacer"
[node name="PlayerNameField" type="TextEdit" parent="VBoxContainer/AddPlayerContainer" unique_id=332535327]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(2.08165e-12, 40) custom_minimum_size = Vector2(220, 0)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Player name" placeholder_text = "Player name"
script = ExtResource("7_qsdfy") max_length = 10
[node name="AddPlayerButton" type="Button" parent="VBoxContainer/AddPlayerContainer" unique_id=245001313] [node name="AddPlayerButton" type="Button" parent="VBoxContainer/AddPlayerContainer" unique_id=245001313]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2 layout_mode = 2
focus_mode = 0
text = "Add" text = "Add"
[node name="Panel2" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=60215448] [node name="AddPlayerHint" type="Label" parent="VBoxContainer/AddPlayerContainer"]
custom_minimum_size = Vector2(2.08165e-12, 40) unique_name_in_owner = true
visible = false
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 theme_type_variation = &"HighlightMedium"
theme_type_variation = &"DarkSpacer" text = "Up to 7 players"
vertical_alignment = 1
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer" unique_id=1000000001] [node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer" unique_id=1000000001]
layout_mode = 2 layout_mode = 2
@@ -84,5 +92,6 @@ horizontal_scroll_mode = 0
[node name="PlayersListVBoxContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer" unique_id=1000000002] [node name="PlayersListVBoxContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer" unique_id=1000000002]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
theme_override_constants/separation = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 15 size_flags_vertical = 15
+19 -19
View File
@@ -29,49 +29,49 @@ layout_mode = 0
offset_left = 64.0 offset_left = 64.0
offset_top = 384.0 offset_top = 384.0
offset_right = 1152.0 offset_right = 1152.0
offset_bottom = 896.0 offset_bottom = 894.0
[node name="InspirationList" parent="." unique_id=498283761 instance=ExtResource("scene_InspirationList")] [node name="InspirationList" parent="." unique_id=498283761 instance=ExtResource("scene_InspirationList")]
layout_mode = 0 layout_mode = 0
offset_left = 1216.0 offset_left = 1216.0
offset_top = 128.0 offset_top = 128.0
offset_right = 1857.0 offset_right = 1856.0
offset_bottom = 512.0 offset_bottom = 512.0
[node name="NowPlaying" parent="." unique_id=42468498 instance=ExtResource("scene_NowPlaying")] [node name="NowPlaying" parent="." unique_id=42468498 instance=ExtResource("scene_NowPlaying")]
layout_mode = 0 layout_mode = 0
offset_left = 64.0 offset_left = 64.0
offset_top = 64.0 offset_top = 56.0
offset_right = 1152.0 offset_right = 1152.0
offset_bottom = 384.0 offset_bottom = 384.0
[node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")] [node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")]
layout_mode = 0 layout_mode = 0
offset_left = 64.0 offset_left = 64.0
offset_top = 984.0 offset_top = 970.0
offset_right = 1856.0 offset_right = 1856.0
offset_bottom = 1026.0 offset_bottom = 1012.0
[node name="PlayersPanel" parent="." unique_id=1708375945 instance=ExtResource("scene_PlayersPanel")] [node name="PlayersPanel" parent="." unique_id=1708375945 instance=ExtResource("scene_PlayersPanel")]
layout_mode = 0 layout_mode = 0
offset_left = 1216.0 offset_left = 1216.0
offset_top = 576.0 offset_top = 544.0
offset_right = 1857.0 offset_right = 1856.0
offset_bottom = 977.0 offset_bottom = 954.0
[node name="QuizToolbar" parent="." unique_id=1873301941 instance=ExtResource("scene_QuizToolbar")] [node name="QuizToolbar" parent="." unique_id=1873301941 instance=ExtResource("scene_QuizToolbar")]
layout_mode = 0 layout_mode = 0
offset_left = 1360.0 offset_left = 1216.0
offset_top = 64.0 offset_top = 64.0
offset_right = 1855.0 offset_right = 1856.0
offset_bottom = 95.0 offset_bottom = 100.0
[node name="SongControls" parent="." unique_id=1640212773 instance=ExtResource("scene_SongControls")] [node name="SongControls" parent="." unique_id=1640212773 instance=ExtResource("scene_SongControls")]
layout_mode = 0 layout_mode = 0
offset_left = 65.0 offset_left = 64.0
offset_top = 944.0 offset_top = 918.0
offset_right = 368.0 offset_right = 704.0
offset_bottom = 975.0 offset_bottom = 954.0
[node name="CharacterSelectPopup" parent="." unique_id=1679856322 instance=ExtResource("6_dhrvg")] [node name="CharacterSelectPopup" parent="." unique_id=1679856322 instance=ExtResource("6_dhrvg")]
unique_name_in_owner = true unique_name_in_owner = true
@@ -94,9 +94,9 @@ offset_bottom = 80.0
[node name="QuizSettingsButton" type="Button" parent="." unique_id=1323246979] [node name="QuizSettingsButton" type="Button" parent="." unique_id=1323246979]
layout_mode = 0 layout_mode = 0
offset_left = 64.0 offset_left = 64.0
offset_top = 1030.0 offset_top = 1028.0
offset_right = 224.0 offset_right = 224.0
offset_bottom = 1066.0 offset_bottom = 1064.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "Quiz settings" text = "Quiz settings"
@@ -107,9 +107,9 @@ expand_icon = true
visible = false visible = false
layout_mode = 0 layout_mode = 0
offset_left = 64.0 offset_left = 64.0
offset_top = 984.0 offset_top = 962.0
offset_right = 64.0 offset_right = 64.0
offset_bottom = 984.0 offset_bottom = 962.0
[node name="SongLoader" type="Node" parent="." unique_id=1520867349] [node name="SongLoader" type="Node" parent="." unique_id=1520867349]
script = ExtResource("song_loader_script") script = ExtResource("song_loader_script")
+49
View File
@@ -50,6 +50,49 @@ focus_mode = 0
action_mode = 0 action_mode = 0
text = "Hide next track" text = "Hide next track"
[node name="ShortSongsCheckButton" type="CheckButton" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
tooltip_text = "Play only a part of each song, from a random point, so more songs fit in a game"
focus_mode = 0
action_mode = 0
text = "Short songs"
[node name="ShortSongSecondsRow" type="HBoxContainer" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
[node name="Title" type="Label" parent="CardPanel/VBox/ShortSongSecondsRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Seconds per song"
[node name="ShortSongSecondsLabel" type="Label" parent="CardPanel/VBox/ShortSongSecondsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "30"
horizontal_alignment = 1
[node name="LowerShortSongSecondsButton" type="Button" parent="CardPanel/VBox/ShortSongSecondsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 0)
layout_mode = 2
focus_mode = 0
action_mode = 0
theme_type_variation = &"StepButton"
text = "-5"
[node name="IncreaseShortSongSecondsButton" type="Button" parent="CardPanel/VBox/ShortSongSecondsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 0)
layout_mode = 2
focus_mode = 0
action_mode = 0
theme_type_variation = &"StepButton"
text = "+5"
[node name="HideBeginningCheckButton" type="CheckButton" parent="CardPanel/VBox"] [node name="HideBeginningCheckButton" type="CheckButton" parent="CardPanel/VBox"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
@@ -80,6 +123,7 @@ custom_minimum_size = Vector2(30, 0)
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "-1" text = "-1"
[node name="IncreaseHideForSecondsButton" type="Button" parent="CardPanel/VBox/HideForSecondsRow"] [node name="IncreaseHideForSecondsButton" type="Button" parent="CardPanel/VBox/HideForSecondsRow"]
@@ -88,6 +132,7 @@ custom_minimum_size = Vector2(30, 0)
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "+1" text = "+1"
[node name="HideLengthCheckButton" type="CheckButton" parent="CardPanel/VBox"] [node name="HideLengthCheckButton" type="CheckButton" parent="CardPanel/VBox"]
@@ -127,6 +172,7 @@ custom_minimum_size = Vector2(30, 0)
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "-1" text = "-1"
[node name="IncreaseButton" type="Button" parent="CardPanel/VBox/WinningScoreRow"] [node name="IncreaseButton" type="Button" parent="CardPanel/VBox/WinningScoreRow"]
@@ -135,6 +181,7 @@ custom_minimum_size = Vector2(30, 0)
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "+1" text = "+1"
[node name="InspirationRow" type="HBoxContainer" parent="CardPanel/VBox"] [node name="InspirationRow" type="HBoxContainer" parent="CardPanel/VBox"]
@@ -159,6 +206,7 @@ custom_minimum_size = Vector2(30, 0)
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "-1" text = "-1"
[node name="IncreaseInspirationSpeedButton" type="Button" parent="CardPanel/VBox/InspirationRow"] [node name="IncreaseInspirationSpeedButton" type="Button" parent="CardPanel/VBox/InspirationRow"]
@@ -167,4 +215,5 @@ custom_minimum_size = Vector2(30, 0)
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
theme_type_variation = &"StepButton"
text = "+1" text = "+1"
+7 -18
View File
@@ -10,45 +10,34 @@ keycode = 83
[sub_resource type="Shortcut" id="Shortcut_search"] [sub_resource type="Shortcut" id="Shortcut_search"]
events = [SubResource("InputEventKey_search")] events = [SubResource("InputEventKey_search")]
[node name="QuizToolbar" type="Control" unique_id=1873301941] [node name="QuizToolbar" type="HBoxContainer" unique_id=1873301941]
layout_mode = 3 layout_mode = 3
offset_right = 495.0
offset_bottom = 31.0
mouse_filter = 2 mouse_filter = 2
theme_override_constants/separation = 12
alignment = 2
script = ExtResource("part_script") script = ExtResource("part_script")
[node name="SearchButton" type="Button" parent="." unique_id=1782453581] [node name="SearchButton" type="Button" parent="." unique_id=1782453581]
layout_mode = 0 layout_mode = 2
offset_right = 111.0
offset_bottom = 31.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
shortcut = SubResource("Shortcut_search") shortcut = SubResource("Shortcut_search")
text = "Search" text = "Search"
[node name="ResetPlaylistButton" type="Button" parent="." unique_id=1754155620] [node name="ResetPlaylistButton" type="Button" parent="." unique_id=1754155620]
layout_mode = 0 layout_mode = 2
offset_left = 128.0
offset_right = 239.0
offset_bottom = 31.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "Reset playlist" text = "Reset playlist"
[node name="ResetPointsButton" type="Button" parent="." unique_id=942880776] [node name="ResetPointsButton" type="Button" parent="." unique_id=942880776]
layout_mode = 0 layout_mode = 2
offset_left = 256.0
offset_right = 367.0
offset_bottom = 31.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "Reset points" text = "Reset points"
[node name="SoundTestButton" type="Button" parent="." unique_id=1332971879] [node name="SoundTestButton" type="Button" parent="." unique_id=1332971879]
layout_mode = 0 layout_mode = 2
offset_left = 384.0
offset_right = 495.0
offset_bottom = 31.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "Sound test" text = "Sound test"
+6 -11
View File
@@ -20,29 +20,24 @@ unicode = 231
[sub_resource type="Shortcut" id="Shortcut_d6fml"] [sub_resource type="Shortcut" id="Shortcut_d6fml"]
events = [SubResource("InputEventKey_7dnqw")] events = [SubResource("InputEventKey_7dnqw")]
[node name="SongControls" type="Control" unique_id=1640212773] [node name="SongControls" type="HBoxContainer" unique_id=1640212773]
layout_mode = 3 layout_mode = 3
offset_right = 303.0
offset_bottom = 31.0
mouse_filter = 2 mouse_filter = 2
theme_override_constants/separation = 12
alignment = 0
script = ExtResource("part_script") script = ExtResource("part_script")
[node name="ShowAnswerButton" type="Button" parent="." unique_id=415388039] [node name="ShowAnswerButton" type="Button" parent="." unique_id=415388039]
layout_mode = 0 layout_mode = 2
offset_right = 111.0
offset_bottom = 31.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
shortcut = SubResource("Shortcut_a7fvb") shortcut = SubResource("Shortcut_a7fvb")
text = "Show answer" text = "Show answer"
[node name="NextButton" type="Button" parent="." unique_id=1744703488] [node name="NextButton" type="Button" parent="." unique_id=1744703488]
layout_mode = 0 layout_mode = 2
offset_left = 128.0
offset_right = 303.0
offset_bottom = 31.0
focus_mode = 0 focus_mode = 0
disabled = true disabled = true
action_mode = 0 action_mode = 0
shortcut = SubResource("Shortcut_d6fml") shortcut = SubResource("Shortcut_d6fml")
text = "Randomize new track" text = "Next song"
+19 -1
View File
@@ -1,12 +1,18 @@
class_name MusicListPanel class_name MusicListPanel
extends PanelContainer 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. ## Emitted when the row of song number [param song_no] is clicked.
signal song_clicked(song_no: int) signal song_clicked(song_no: int)
var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn") 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 @onready var _rows: VBoxContainer = %MusicList
@@ -15,6 +21,7 @@ var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
func refresh() -> void: func refresh() -> void:
Settings.delete_children(_rows) Settings.delete_children(_rows)
var songs: Array[SongObject] = Playlist.get_playlist() var songs: Array[SongObject] = Playlist.get_playlist()
var state: Dictionary = {}
for i: int in songs.size(): for i: int in songs.size():
var s: SongObject = songs[i] var s: SongObject = songs[i]
var new_song_list_object: SongRow = _song_list_object.instantiate() 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.index = i
new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number)) new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number))
_rows.add_child(new_song_list_object) _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: func _on_row_input(event: InputEvent, song_no: int) -> void:
+101 -6
View File
@@ -1,10 +1,105 @@
class_name NowPlaying class_name NowPlaying
extends VBoxContainer extends HBoxContainer
## 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. Once the answer is shown, the soundtrack's cover shows on
## their left, when it has one, and the cover and titles are centered together. Both are
## centered up and down too, so the titles only move sideways when a cover comes. Each
## title has at most two lines.
## The covers on disk. No cover is shown without it.
var covers: CoverCache:
set(value):
covers = value
if covers != null:
covers.cover_times_changed.connect(_load_cover)
_load_cover()
# The soundtrack whose cover is shown or on its way, and the cover time asked for
var _cover_uuid: String = ""
var _cover_time: int = 0
@onready var soundtrack_label: Label = %SoundtrackLabel
@onready var song_label: Label = %SongLabel
@onready var cover_frame: Panel = %CoverFrame
@onready var cover_picture: TextureRect = %CoverPicture
@onready var titles: VBoxContainer = %Titles
func _ready() -> void:
resized.connect(_fit_titles)
_fit_titles()
## Shows [param soundtrack_title] and [param song_title]. The playlist hides them as ## Shows [param soundtrack_title] and [param song_title]. The playlist hides them as
## question marks until the answer is shown. ## question marks until the answer is shown, and only then gives the
func show_song(soundtrack_title: String, song_title: String) -> void: ## [param soundtrack_uuid] whose cover is shown.
$SoundtrackLabel.text = soundtrack_title func show_song(soundtrack_title: String, song_title: String,
$SongLabel.text = song_title soundtrack_uuid: String = "") -> void:
var changed: bool = soundtrack_label.text != soundtrack_title \
|| song_label.text != song_title
soundtrack_label.text = soundtrack_title
song_label.text = song_title
_fit_titles()
if soundtrack_uuid != _cover_uuid:
_cover_uuid = soundtrack_uuid
_cover_time = 0
_show_cover(null)
_load_cover()
# A new song, or the answer being shown, fades in
if changed:
Anim.fade_in(self, Anim.SLOW)
## Whether a cover is shown.
func has_cover() -> bool:
return cover_frame.visible
# Asks for the cover, again when its time changed, e.g. once the soundtrack list loaded
func _load_cover() -> void:
if covers == null || _cover_uuid.is_empty() || !is_node_ready():
return
var time: int = covers.cover_time(_cover_uuid)
if time == _cover_time:
return
_cover_time = time
if time <= 0:
_show_cover(null)
return
covers.fetch_cover(_cover_uuid, time, _show_cover_of.bind(_cover_uuid, time))
# Covers arrive later; one for a song no longer shown is left out
func _show_cover_of(cover: Image, uuid: String, time: int) -> void:
if uuid == _cover_uuid && time == _cover_time:
_show_cover(cover)
func _show_cover(cover: Image) -> void:
var was_shown: bool = cover_frame.visible
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
cover_frame.visible = cover != null
_fit_titles()
if cover_frame.visible && !was_shown:
Anim.fade_in(cover_frame, Anim.SLOW)
# The titles take the width of the longer one, so they stay next to the cover, but no
# more than there is room for; longer titles wrap. Beside a cover they line up with it
# on the left.
func _fit_titles() -> void:
var room: float = size.x
if cover_frame.visible:
room -= cover_frame.custom_minimum_size.x + get_theme_constant(&"separation")
var widest: float = maxf(_text_width(soundtrack_label), _text_width(song_label))
titles.custom_minimum_size.x = clampf(ceilf(widest), 1.0, maxf(room, 1.0))
var alignment: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT if cover_frame.visible \
else HORIZONTAL_ALIGNMENT_CENTER
soundtrack_label.horizontal_alignment = alignment
song_label.horizontal_alignment = alignment
static func _text_width(label: Label) -> float:
var font: Font = label.get_theme_font(&"font")
return font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1,
label.get_theme_font_size(&"font_size")).x
-49
View File
@@ -1,49 +0,0 @@
class_name PlayerNameField
extends TextEdit
## The field for a new player's name. It keeps to one line of at most
## [constant MAX_NAME_LENGTH] characters, and reports Enter so the player can be added.
## Emitted when Enter is pressed while the field has focus.
signal enter_key_pressed
## Emitted when Alt+A is pressed.
signal close_pressed
## The longest name a player can have.
const MAX_NAME_LENGTH: int = 10
var current_text: String = ""
var cursor_line: int = 0
var cursor_column: int = 0
func _ready() -> void:
text_changed.connect(_on_text_changed)
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed:
if event.alt_pressed && event.keycode == KEY_A:
close_pressed.emit()
if has_focus():
if event.keycode == KEY_ENTER || event.keycode == KEY_KP_ENTER:
enter_key_pressed.emit()
func _on_text_changed() -> void:
if text.contains("\n"):
text = text.replace("\n", "")
var new_text: String = text
if new_text.length() > MAX_NAME_LENGTH:
text = current_text
# when replacing the text, the cursor will get moved to the beginning of the
# text, so move it back to where it was
set_caret_line(cursor_line)
set_caret_column(cursor_column)
current_text = text
# save current position of cursor for when we have reached the limit
cursor_line = get_caret_line()
cursor_column = get_caret_column()
-1
View File
@@ -1 +0,0 @@
uid://cgakje8gb37tf
+60 -13
View File
@@ -1,5 +1,10 @@
class_name PlayerRow class_name PlayerRow
extends Control 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 change_character_clicked
signal player_removed signal player_removed
@@ -12,6 +17,9 @@ var player_object: PlayerObject
var list_index: int var list_index: int
@onready var player_name_field: Label = %Name @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 add_button: Button = %AddPoint
@onready var minus_button: Button = %RemovePoint @onready var minus_button: Button = %RemovePoint
@onready var character: TextureButton = %Character @onready var character: TextureButton = %Character
@@ -25,12 +33,23 @@ func _ready() -> void:
minus_button.set_shortcut(create_button_shortcut(list_index, true)) minus_button.set_shortcut(create_button_shortcut(list_index, true))
character.pressed.connect(change_character) character.pressed.connect(change_character)
remove_player.pressed.connect(func() -> void: player_removed.emit()) 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
theme_type_variation = &"ListRowOdd" if list_index % 2 == 1 else &"ListRowEven"
player_name_field.text = player_object.player_name
player_name_field.tooltip_text = player_object.player_name
set_editing(Settings.edit_players)
update_score() update_score()
set_player_character() set_player_character()
func _process(delta: float) -> void: ## Shows the button that removes the player while the player list is being edited.
remove_player.visible = Settings.edit_players func set_editing(editing: bool) -> void:
remove_player.visible = editing
func create_button_shortcut(index: int, ctrl_pressed: bool) -> Shortcut: func create_button_shortcut(index: int, ctrl_pressed: bool) -> Shortcut:
@@ -57,22 +76,50 @@ func change_character() -> void:
change_character_clicked.emit() change_character_clicked.emit()
func new_character_selected(character_texture: ImageTexture) -> void: func new_character_selected(character_texture: Texture, character_name: String) -> void:
GameLog.debug("Back in player list with new") GameLog.debug("Back in player list with new")
player_object.character = character_texture player_object.set_character(character_texture, character_name)
set_player_character() set_player_character()
func set_player_character() -> void: func set_player_character() -> void:
character.custom_minimum_size = Vector2(80, 40)
character.ignore_texture_size = true
character.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
character.texture_normal = player_object.character character.texture_normal = player_object.character
func update_score() -> void: func update_score() -> void:
if OS.has_feature("debug"): score_label.text = str(player_object.player_score)
player_name_field.text = "%d %s: %d" % [list_index + 1, player_object.player_name, _show_match_point(is_match_point())
player_object.player_score]
else:
player_name_field.text = player_object.player_name + ": " + str(player_object.player_score) ## 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:
character_frame.theme_type_variation = &"PlayerCharacterHover" if hovered \
else &"PlayerCharacter"
+35 -8
View File
@@ -21,37 +21,54 @@ var _player_row: PackedScene = preload("res://quiz/PlayerRow.tscn")
@onready var add_players_button: TextureButton = %AddPlayersButton @onready var add_players_button: TextureButton = %AddPlayersButton
@onready var add_player_container: HBoxContainer = %AddPlayerContainer @onready var add_player_container: HBoxContainer = %AddPlayerContainer
@onready var add_player_button: Button = %AddPlayerButton @onready var add_player_button: Button = %AddPlayerButton
@onready var new_player_name_field: PlayerNameField = %PlayerNameField @onready var add_player_hint: Label = %AddPlayerHint
@onready var new_player_name_field: LineEdit = %PlayerNameField
@onready var players_list_container: VBoxContainer = %PlayersListVBoxContainer @onready var players_list_container: VBoxContainer = %PlayersListVBoxContainer
func _ready() -> void: func _ready() -> void:
add_players_button.pressed.connect(add_players) add_players_button.pressed.connect(add_players)
add_player_button.pressed.connect(add_player) add_player_button.pressed.connect(add_player)
new_player_name_field.enter_key_pressed.connect(add_player) new_player_name_field.text_submitted.connect(func(_text: String) -> void: add_player())
## Shows or hides the row for adding players, and the buttons for removing them. ## Shows or hides the row for adding players, and the buttons for removing them.
func add_players() -> void: func add_players() -> void:
GameLog.debug("add_players") GameLog.debug("add_players")
Settings.edit_players = !Settings.edit_players Settings.edit_players = !Settings.edit_players
add_player_container.visible = !add_player_container.visible add_player_container.visible = Settings.edit_players
for row: PlayerRow in players_list_container.get_children():
row.set_editing(Settings.edit_players)
_update_add_row()
if Settings.edit_players:
new_player_name_field.grab_focus() new_player_name_field.grab_focus()
## Adds a player with the name in the name field, unless it is empty or there are ## Adds a player with the name in the name field. Blank names, names another player
## already as many players as there can be. ## already has, and players beyond [constant Settings.MAX_NUMBER_OF_PLAYERS] are not
## added.
func add_player() -> void: func add_player() -> void:
GameLog.debug("add_player") GameLog.debug("add_player")
if new_player_name_field.text != "" \ var player_name: String = new_player_name_field.text.strip_edges()
&& Settings.player_array.size() < Settings.MAX_NUMBER_OF_PLAYERS: if !can_add(player_name):
var new_player_object: PlayerObject = PlayerObject.new(new_player_name_field.text) return
var new_player_object: PlayerObject = PlayerObject.new(player_name)
new_player_name_field.text = "" new_player_name_field.text = ""
Settings.player_array.append(new_player_object) Settings.player_array.append(new_player_object)
player_added.emit(new_player_object) player_added.emit(new_player_object)
reload() reload()
## Whether a player called [param player_name] can be added.
func can_add(player_name: String) -> bool:
if player_name.is_empty() || Settings.player_array.size() >= Settings.MAX_NUMBER_OF_PLAYERS:
return false
for player: PlayerObject in Settings.player_array:
if player.player_name.to_lower() == player_name.to_lower():
return false
return true
## Shows the players in [code]Settings.player_array[/code] again, e.g. after their ## Shows the players in [code]Settings.player_array[/code] again, e.g. after their
## points were reset. ## points were reset.
func reload() -> void: func reload() -> void:
@@ -71,6 +88,16 @@ func reload() -> void:
players_list_container.add_child(new_player) players_list_container.add_child(new_player)
counter += 1 counter += 1
_update_add_row()
# A full list can't take more players, so the name field gives way to a note saying so
func _update_add_row() -> void:
var full: bool = Settings.player_array.size() >= Settings.MAX_NUMBER_OF_PLAYERS
add_player_hint.text = "Up to %d players" % Settings.MAX_NUMBER_OF_PLAYERS
add_player_hint.visible = full
new_player_name_field.visible = !full
add_player_button.visible = !full
func _on_change_character_clicked(row: PlayerRow, player_name: String) -> void: func _on_change_character_clicked(row: PlayerRow, player_name: String) -> void:
+132 -5
View File
@@ -6,6 +6,9 @@ extends Control
## The main window shows the quiz below its top bar. Pictures and buttons keep the ## The main window shows the quiz below its top bar. Pictures and buttons keep the
## positions they had in the main window, so the quiz fills the whole window and lets ## positions they had in the main window, so the quiz fills the whole window and lets
## clicks through where it has nothing. ## clicks through where it has nothing.
##
## The quiz is saved after every change and continued when the game starts again, see
## [QuizSave].
## Emitted when another MusicServer is picked in the settings, after the quiz has ## Emitted when another MusicServer is picked in the settings, after the quiz has
## reloaded from it. ## reloaded from it.
@@ -26,6 +29,11 @@ var stats_paused_by_win: bool = false
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it. ## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
var popup_backdrop: PopupBackdrop var popup_backdrop: PopupBackdrop
# True from start until the saved quiz's songs are back, so a half restored quiz is
# never saved over the whole one
var _restoring: bool = false
var _save_queued: bool = false
@onready var inspiration_list: InspirationList = $InspirationList @onready var inspiration_list: InspirationList = $InspirationList
@onready var now_playing: NowPlaying = $NowPlaying @onready var now_playing: NowPlaying = $NowPlaying
@onready var players_panel: PlayersPanel = $PlayersPanel @onready var players_panel: PlayersPanel = $PlayersPanel
@@ -50,7 +58,7 @@ func _ready() -> void:
quiz_settings_button.pressed.connect(quiz_settings_card.toggle) quiz_settings_button.pressed.connect(quiz_settings_card.toggle)
toolbar.reset_playlist_pressed.connect(reset_playlist) toolbar.reset_playlist_pressed.connect(reset_playlist)
toolbar.reset_points_pressed.connect(reset_points) toolbar.reset_points_pressed.connect(reset_points)
song_loader.song_list_loaded.connect(update_song_list) song_loader.song_list_loaded.connect(_on_song_list_loaded)
song_loader.song_added.connect(_on_song_added) song_loader.song_added.connect(_on_song_added)
character_select_popup.character_selected.connect(_on_new_character_selected) character_select_popup.character_selected.connect(_on_new_character_selected)
@@ -60,10 +68,15 @@ func _ready() -> void:
players_panel.change_character_requested.connect(_on_change_character_requested) players_panel.change_character_requested.connect(_on_change_character_requested)
music_player.play_next_song.connect(play_next_song) music_player.play_next_song.connect(play_next_song)
music_player.update_song_list.connect(update_song_list) music_player.update_song_list.connect(update_song_list)
character_select_popup.source_changed.connect(_on_character_source_changed)
song_loader.saved_songs_dropped.connect(_on_saved_songs_dropped)
var saved: QuizSave = QuizSave.read()
_continue_saved_quiz(saved)
inspiration_list.load_soundtracks() inspiration_list.load_soundtracks()
song_loader.load_song_list() song_loader.load_song_list(saved)
character_select_popup.load_characters() character_select_popup.load_characters()
_load_saved_characters(saved)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
@@ -151,14 +164,124 @@ func _on_song_added() -> void:
song_controls.set_next_enabled(true) song_controls.set_next_enabled(true)
if Settings.is_debug: if Settings.is_debug:
update_song_list() update_song_list()
save_soon()
func update_song_list() -> void: func update_song_list() -> void:
GameLog.debug("update_song_list") GameLog.debug("update_song_list")
if Playlist.currently_playing_song >= 0: if Playlist.currently_playing_song >= 0:
now_playing.show_song(Playlist.get_current_soundtrack_title(), now_playing.show_song(Playlist.get_current_soundtrack_title(),
Playlist.get_current_song_title()) Playlist.get_current_song_title(), Playlist.get_current_soundtrack_uuid())
music_list_panel.refresh() music_list_panel.refresh()
save_soon()
## Saves the quiz at the end of this frame, once however many changes it had.
func save_soon() -> void:
if _restoring || _save_queued:
return
_save_queued = true
save_now.call_deferred()
## Saves the quiz at once, e.g. before the game restarts with a new version.
func save_now() -> void:
_save_queued = false
if is_inside_tree():
QuizSave.capture(stats_paused_by_win).write()
# Puts back the saved quiz's music folder, players and statistics. Its songs come back
# with the song list, see SongLoader.load_song_list(), and its characters' pictures once
# the characters are loaded.
func _continue_saved_quiz(saved: QuizSave) -> void:
if saved == null:
Playlist.clear_song_files()
return
_restoring = true
if !saved.music_folder_path.is_empty():
var folder: MusicFolder = MusicFolder.scan(saved.music_folder_path)
if folder.kind == MusicFolder.Kind.EMPTY:
GameLog.warning("The saved quiz's music folder is gone: ", saved.music_folder_path)
saved.drop_songs()
else:
folder.restore_progress(saved.music_folder_progress)
Settings.music_folder = folder
elif saved.server != Settings.default_path:
# Another server has another playlist
saved.drop_songs()
Playlist.clear_song_files(saved.song_files())
for player: PlayerObject in saved.players:
Settings.player_array.append(player)
_on_player_added(player)
if saved.characters_dir != Settings.characters_dir():
for player: PlayerObject in saved.players:
player.clear_character()
players_panel.reload()
if saved.stats_paused_by_win:
stats_paused_by_win = true
Settings.add_to_stats = false
# The players' characters are saved by name; their pictures come from the characters
func _load_saved_characters(saved: QuizSave) -> void:
if saved == null:
return
for player: PlayerObject in saved.players:
var character_name: String = player.character_name
if character_name.is_empty():
continue
character_select_popup.source.fetch_image(character_name, func(picture: Image) -> void:
# Another character may have been picked meanwhile
if player.character_name == character_name:
player.character = ImageTexture.create_from_image(picture)
players_panel.reload())
func _on_song_list_loaded() -> void:
if _restoring:
_finish_restoring()
update_song_list()
# The saved songs are back: the song that was playing is ready to play again, and songs
# whose files are gone are downloaded again
func _finish_restoring() -> void:
_restoring = false
if !Settings.is_offline():
for i: int in Playlist.song_object_array.size():
var song_object: SongObject = Playlist.song_object_array[i]
var needed: bool = !song_object.has_played || i == Playlist.currently_playing_song
if needed && Playlist.get_song(i) == null:
MusicServerClient.fetch_song(song_object.song_number,
_on_saved_song_downloaded.bind(i))
if Playlist.currently_playing_song >= 0:
music_player.load_song_object(Playlist.currently_playing_song)
if Playlist.get_preloaded_songs() > 0:
song_controls.set_next_enabled(true)
func _on_saved_song_downloaded(body: PackedByteArray, song_no: int) -> void:
if song_no >= Playlist.song_object_array.size():
return
Playlist.add_song_to_song_object(song_no, body)
if song_no == Playlist.currently_playing_song && !music_player.audio_player.playing:
music_player.load_song_object(song_no)
save_soon()
func _on_saved_songs_dropped() -> void:
for player: PlayerObject in Settings.player_array:
player.reset_points()
players_panel.reload()
# Characters picked before are not among the new ones, so everyone picks again
func _on_character_source_changed() -> void:
for player: PlayerObject in Settings.player_array:
player.clear_character()
players_panel.reload()
save_soon()
func song_object_clicked(song_no: int) -> void: func song_object_clicked(song_no: int) -> void:
@@ -204,6 +327,7 @@ func reset_points() -> void:
if stats_paused_by_win: if stats_paused_by_win:
Settings.add_to_stats = true Settings.add_to_stats = true
stats_paused_by_win = false stats_paused_by_win = false
save_soon()
# Each new player's points reach the music player, the log and the winner popup. # Each new player's points reach the music player, the log and the winner popup.
@@ -214,6 +338,7 @@ func _on_player_added(new_player_object: PlayerObject) -> void:
new_player_object.point_given_sound.connect(_on_make_point_given_sound) new_player_object.point_given_sound.connect(_on_make_point_given_sound)
new_player_object.player_point_given.connect(_on_point_given.bind(new_player_object)) new_player_object.player_point_given.connect(_on_point_given.bind(new_player_object))
new_player_object.player_point_taken.connect(_on_point_taken.bind(new_player_object)) new_player_object.player_point_taken.connect(_on_point_taken.bind(new_player_object))
save_soon()
func _on_point_given(player_object: PlayerObject) -> void: func _on_point_given(player_object: PlayerObject) -> void:
@@ -259,6 +384,7 @@ func _on_player_won(player_object: PlayerObject) -> void:
if Settings.add_to_stats: if Settings.add_to_stats:
Settings.add_to_stats = false Settings.add_to_stats = false
stats_paused_by_win = true stats_paused_by_win = true
save_soon()
func _on_player_removed(player_object: PlayerObject) -> void: func _on_player_removed(player_object: PlayerObject) -> void:
@@ -273,6 +399,7 @@ func _on_change_character_requested(new_player: PlayerRow, player_name: String)
character_select_popup.show_grid(player_name) character_select_popup.show_grid(player_name)
func _on_new_character_selected(character_texture: ImageTexture) -> void: func _on_new_character_selected(character_texture: ImageTexture, character_name: String) -> void:
GameLog.debug("_on_new_character_selected") GameLog.debug("_on_new_character_selected")
current_player_row.new_character_selected(character_texture) current_player_row.new_character_selected(character_texture, character_name)
save_soon()
+34
View File
@@ -13,6 +13,10 @@ extends BasePopup
## The most seconds of a song's beginning that can be hidden. ## The most seconds of a song's beginning that can be hidden.
const MAX_HIDE_SECONDS: float = 10.0 const MAX_HIDE_SECONDS: float = 10.0
## The shortest and longest part of a song that Short songs plays, and the step between.
const MIN_SHORT_SONG_SECONDS: int = 10
const MAX_SHORT_SONG_SECONDS: int = 120
const SHORT_SONG_STEP: int = 5
## Space between the rows and the card's edges. ## Space between the rows and the card's edges.
const PADDING: Vector2 = Vector2(16, 12) const PADDING: Vector2 = Vector2(16, 12)
@@ -21,6 +25,11 @@ const PADDING: Vector2 = Vector2(16, 12)
@onready var auto_repeat_button: CheckButton = %AutoRepeatCheckButton @onready var auto_repeat_button: CheckButton = %AutoRepeatCheckButton
@onready var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton @onready var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton
@onready var hide_next_track_button: CheckButton = %HideNextTrackCheckButton @onready var hide_next_track_button: CheckButton = %HideNextTrackCheckButton
@onready var short_songs_button: CheckButton = %ShortSongsCheckButton
@onready var short_song_seconds_row: HBoxContainer = %ShortSongSecondsRow
@onready var short_song_seconds_label: Label = %ShortSongSecondsLabel
@onready var lower_short_song_seconds_button: Button = %LowerShortSongSecondsButton
@onready var increase_short_song_seconds_button: Button = %IncreaseShortSongSecondsButton
@onready var hide_beginning_button: CheckButton = %HideBeginningCheckButton @onready var hide_beginning_button: CheckButton = %HideBeginningCheckButton
@onready var hide_for_seconds_row: HBoxContainer = %HideForSecondsRow @onready var hide_for_seconds_row: HBoxContainer = %HideForSecondsRow
@onready var hide_for_seconds_label: Label = %HideForSecondsLabel @onready var hide_for_seconds_label: Label = %HideForSecondsLabel
@@ -41,6 +50,11 @@ func _ready() -> void:
auto_repeat_button.toggled.connect(_save_toggle.bind(&"auto_repeat_song")) auto_repeat_button.toggled.connect(_save_toggle.bind(&"auto_repeat_song"))
stop_after_current_button.toggled.connect(_save_toggle.bind(&"stop_after_current")) stop_after_current_button.toggled.connect(_save_toggle.bind(&"stop_after_current"))
hide_next_track_button.toggled.connect(_save_toggle.bind(&"hide_next_track")) hide_next_track_button.toggled.connect(_save_toggle.bind(&"hide_next_track"))
short_songs_button.toggled.connect(_on_short_songs_toggled)
lower_short_song_seconds_button.pressed.connect(
set_short_song_seconds.bind(-SHORT_SONG_STEP, true))
increase_short_song_seconds_button.pressed.connect(
set_short_song_seconds.bind(SHORT_SONG_STEP, true))
hide_beginning_button.toggled.connect(_on_hide_beginning_toggled) hide_beginning_button.toggled.connect(_on_hide_beginning_toggled)
hide_length_button.toggled.connect(_save_toggle.bind(&"hide_length")) hide_length_button.toggled.connect(_save_toggle.bind(&"hide_length"))
hide_ticks_button.toggled.connect(_save_toggle.bind(&"hide_ticks")) hide_ticks_button.toggled.connect(_save_toggle.bind(&"hide_ticks"))
@@ -60,6 +74,9 @@ func show_settings() -> void:
auto_repeat_button.set_pressed_no_signal(Settings.auto_repeat_song) auto_repeat_button.set_pressed_no_signal(Settings.auto_repeat_song)
stop_after_current_button.set_pressed_no_signal(Settings.stop_after_current) stop_after_current_button.set_pressed_no_signal(Settings.stop_after_current)
hide_next_track_button.set_pressed_no_signal(Settings.hide_next_track) hide_next_track_button.set_pressed_no_signal(Settings.hide_next_track)
short_songs_button.set_pressed_no_signal(Settings.short_songs)
short_song_seconds_row.visible = Settings.short_songs
short_song_seconds_label.text = str(Settings.short_song_seconds)
hide_beginning_button.set_pressed_no_signal(Settings.hide_beginning) hide_beginning_button.set_pressed_no_signal(Settings.hide_beginning)
hide_length_button.set_pressed_no_signal(Settings.hide_length) hide_length_button.set_pressed_no_signal(Settings.hide_length)
hide_ticks_button.set_pressed_no_signal(Settings.hide_ticks) hide_ticks_button.set_pressed_no_signal(Settings.hide_ticks)
@@ -117,6 +134,23 @@ func _save_toggle(on: bool, setting: StringName) -> void:
Settings.save_settings() Settings.save_settings()
## Sets how many seconds of each song Short songs plays, between
## [constant MIN_SHORT_SONG_SECONDS] and [constant MAX_SHORT_SONG_SECONDS]. With
## [param relative] the seconds are added to the current length.
func set_short_song_seconds(seconds: int, relative: bool = false) -> void:
var length: int = Settings.short_song_seconds + seconds if relative else seconds
Settings.short_song_seconds = clampi(length, MIN_SHORT_SONG_SECONDS,
MAX_SHORT_SONG_SECONDS)
short_song_seconds_label.text = str(Settings.short_song_seconds)
Settings.save_settings()
func _on_short_songs_toggled(on: bool) -> void:
_save_toggle(on, &"short_songs")
short_song_seconds_row.visible = on
_fit_to_rows()
func _on_hide_beginning_toggled(on: bool) -> void: func _on_hide_beginning_toggled(on: bool) -> void:
_save_toggle(on, &"hide_beginning") _save_toggle(on, &"hide_beginning")
hide_for_seconds_row.visible = on hide_for_seconds_row.visible = on
+1 -1
View File
@@ -8,7 +8,7 @@ signal next_pressed
## "Show winner". ## "Show winner".
signal show_winner_pressed signal show_winner_pressed
const NEXT_TEXT: String = "Randomize new track" const NEXT_TEXT: String = "Next song"
const SHOW_WINNER_TEXT: String = "Show winner" const SHOW_WINNER_TEXT: String = "Show winner"
## True while Next reads "Show winner", after the last song of a prepared quiz. ## True while Next reads "Show winner", after the last song of a prepared quiz.
+57 -9
View File
@@ -15,35 +15,79 @@ signal song_list_loaded
## Emitted when a new song has been downloaded and added to the Playlist. ## Emitted when a new song has been downloaded and added to the Playlist.
signal song_added signal song_added
## Emitted when the songs of a saved quiz could not be continued, because the server's
## playlist no longer has them, e.g. after a sync. The quiz starts over.
signal saved_songs_dropped
## Seconds to wait before retrying a download step that failed. ## Seconds to wait before retrying a download step that failed.
var retry_seconds: float = 5.0 var retry_seconds: float = 5.0
## Loads the songs already in the server playlist, then starts preloading new ones. ## Loads the songs already in the server playlist, then starts preloading new ones.
## Preloading waits for the list, because loading the list clears the playlist. ## Preloading waits for the list, because loading the list clears the playlist.
func load_song_list() -> void: ##
## With [param saved], the songs of a saved quiz are used where they match the server's
## playlist, so who got points and what was answered or downloaded ahead is kept. When
## the server's playlist no longer has them, they are dropped and
## [signal saved_songs_dropped] is emitted.
func load_song_list(saved: QuizSave = null) -> void:
GameLog.debug("load_song_list") GameLog.debug("load_song_list")
if Settings.is_offline(): if Settings.is_offline():
_start_folder_playlist() _start_folder_playlist(saved)
return return
var song_list_fetched: Callable = func song_list_fetched(songs: Array[SongInfo]) -> void: var song_list_fetched: Callable = func song_list_fetched(songs: Array[SongInfo]) -> void:
if Settings.is_offline(): if Settings.is_offline():
return return
Playlist.clear_song_list() Playlist.clear_song_list()
for info: SongInfo in songs: var continued: int = 0
if saved != null && !saved.songs.is_empty():
if saved_songs_match(saved.songs, songs):
continued = saved.songs.size()
_continue_songs(saved)
else:
GameLog.warning("The server's playlist changed, so the saved quiz starts over")
saved_songs_dropped.emit()
for info: SongInfo in songs.slice(continued):
var new_song_object: SongObject = SongObject.new() var new_song_object: SongObject = SongObject.new()
new_song_object.song_number = info.song_no new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = info.soundtrack new_song_object.soundtrack_title = info.soundtrack
new_song_object.song_title = info.song new_song_object.song_title = info.song
new_song_object.soundtrack_uuid = info.soundtrack_uuid
new_song_object.is_answered = true new_song_object.is_answered = true
new_song_object.has_played = true new_song_object.has_played = true
Playlist.add_to_playlist(new_song_object) Playlist.add_to_playlist(new_song_object)
if continued == 0:
Playlist.update_last_played_song() Playlist.update_last_played_song()
# Songs already on the server were played before, so the game starts after them # Songs already on the server were played before, so the game starts after them
Playlist.game_start_song = Playlist.song_object_array.size() Playlist.game_start_song = Playlist.song_object_array.size()
song_list_loaded.emit() song_list_loaded.emit()
fetch_song_if_idle() # Songs downloaded ahead before a restart already fill some of the cache
MusicServerClient.fetch_song_list(song_list_fetched, _retry_later.bind(load_song_list)) fill_cache()
MusicServerClient.fetch_song_list(song_list_fetched,
_retry_later.bind(load_song_list.bind(saved)))
## Whether [param saved_songs] are the first songs of the server's playlist [param songs]:
## the same numbers, soundtracks and titles in the same order.
static func saved_songs_match(saved_songs: Array[SongObject], songs: Array[SongInfo]) -> bool:
if saved_songs.size() > songs.size():
return false
for i: int in saved_songs.size():
if saved_songs[i].song_number != songs[i].song_no \
|| saved_songs[i].soundtrack_title != songs[i].soundtrack \
|| saved_songs[i].song_title != songs[i].song:
return false
return true
# Puts the saved songs in the playlist, where the quiz was
func _continue_songs(saved: QuizSave) -> void:
for song_object: SongObject in saved.songs:
Playlist.add_to_playlist(song_object)
Playlist.currently_playing_song = saved.currently_playing_song
Playlist.latest_played_song = saved.latest_played_song
Playlist.game_start_song = saved.game_start_song
Playlist.first_song_played = saved.first_song_played
## Starts downloading one more song, which keeps downloading until the cache is full. ## Starts downloading one more song, which keeps downloading until the cache is full.
@@ -79,7 +123,7 @@ func _download_random_song(new_song_object: SongObject) -> void:
return return
Playlist.store_song(new_song_object, body) Playlist.store_song(new_song_object, body)
_add_song_to_queue(new_song_object) _add_song_to_queue(new_song_object)
MusicServerClient.fetch_random_song(Settings.use_low_played_mode, song_downloaded, MusicServerClient.fetch_random_song(Settings.random_mode, song_downloaded,
_retry_later.bind(_download_random_song.bind(new_song_object))) _retry_later.bind(_download_random_song.bind(new_song_object)))
@@ -96,6 +140,7 @@ func _fetch_song_info(new_song_object: SongObject) -> void:
new_song_object.song_number = info.song_no new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = info.soundtrack new_song_object.soundtrack_title = info.soundtrack
new_song_object.song_title = info.song new_song_object.song_title = info.song
new_song_object.soundtrack_uuid = info.soundtrack_uuid
GameLog.debug("new_song_object: ", new_song_object.to_string()) GameLog.debug("new_song_object: ", new_song_object.to_string())
Playlist.add_to_playlist(new_song_object) Playlist.add_to_playlist(new_song_object)
if Playlist.should_preload_more_songs(): if Playlist.should_preload_more_songs():
@@ -114,14 +159,17 @@ func _retry_later(step: Callable) -> void:
step.call()) step.call())
# A music folder has no playlist on a server, so the game starts with an empty one # A music folder has no playlist on a server, so the game starts with an empty one, or
func _start_folder_playlist() -> void: # with the saved quiz's songs
func _start_folder_playlist(saved: QuizSave = null) -> void:
Playlist.clear_song_list() Playlist.clear_song_list()
Playlist.update_last_played_song() Playlist.update_last_played_song()
Playlist.game_start_song = 0 Playlist.game_start_song = 0
if saved != null && !saved.songs.is_empty():
_continue_songs(saved)
Playlist.is_song_downloading = false Playlist.is_song_downloading = false
song_list_loaded.emit() song_list_loaded.emit()
fetch_song_if_idle() fill_cache()
# Picks songs from the music folder until enough are ready. A prepared quiz runs out # Picks songs from the music folder until enough are ready. A prepared quiz runs out
+22
View File
@@ -0,0 +1,22 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name AlternativeNames
extends RefCounted
## The [code]AlternativeNames[/code] schema of the MusicServer API.
## The new list of alternative names. Replaces the old list; send [] to remove them all.
var names: Array[String]
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> AlternativeNames:
var result: AlternativeNames = AlternativeNames.new()
result.names.assign(ApiJson.get_array(data, "names"))
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"names": names,
}
@@ -0,0 +1 @@
uid://bpdabeiw5wupm
+26
View File
@@ -0,0 +1,26 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name CoverSource
extends RefCounted
## The [code]CoverSource[/code] schema of the MusicServer API.
## The game's ID on SteamGridDB, or the anime's ID on AniList
var id: String
## Where the cover comes from
var source: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> CoverSource:
var result: CoverSource = CoverSource.new()
result.id = ApiJson.get_string(data, "id", "")
result.source = ApiJson.get_string(data, "source", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"id": id,
"source": source,
}
+1
View File
@@ -0,0 +1 @@
uid://06q8sppe2evn
+1
View File
@@ -5,6 +5,7 @@ extends RefCounted
## The [code]ProgressResponse[/code] schema of the MusicServer API. ## The [code]ProgressResponse[/code] schema of the MusicServer API.
var progress: String var progress: String
## How long the sync has been running, as minutes and seconds, e.g. "02:15"
var time_spent: String var time_spent: String
+8
View File
@@ -5,6 +5,10 @@ extends RefCounted
## The [code]Soundtrack[/code] schema of the MusicServer API. ## The [code]Soundtrack[/code] schema of the MusicServer API.
var added: String var added: String
## Other names the soundtrack is known by
var alternative_names: Array[String]
## When the cover last changed, in Unix seconds. Missing when there is no cover.
var cover_modified: int
var last_played: String var last_played: String
var name: String var name: String
var times_played: int var times_played: int
@@ -15,6 +19,8 @@ var uuid: String
static func from_dict(data: Dictionary) -> Soundtrack: static func from_dict(data: Dictionary) -> Soundtrack:
var result: Soundtrack = Soundtrack.new() var result: Soundtrack = Soundtrack.new()
result.added = ApiJson.get_string(data, "added", "") result.added = ApiJson.get_string(data, "added", "")
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
result.last_played = ApiJson.get_string(data, "last_played", "") result.last_played = ApiJson.get_string(data, "last_played", "")
result.name = ApiJson.get_string(data, "name", "") result.name = ApiJson.get_string(data, "name", "")
result.times_played = ApiJson.get_int(data, "times_played", 0) result.times_played = ApiJson.get_int(data, "times_played", 0)
@@ -26,6 +32,8 @@ static func from_dict(data: Dictionary) -> Soundtrack:
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
return { return {
"added": added, "added": added,
"alternative_names": alternative_names,
"cover_modified": cover_modified,
"last_played": last_played, "last_played": last_played,
"name": name, "name": name,
"times_played": times_played, "times_played": times_played,
@@ -5,6 +5,10 @@ extends RefCounted
## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API. ## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API.
var added: String var added: String
## Other names the soundtrack is known by
var alternative_names: Array[String]
## When the cover last changed, in Unix seconds. Missing when there is no cover.
var cover_modified: int
var last_played: String var last_played: String
var name: String var name: String
var songs: Array[Song] var songs: Array[Song]
@@ -16,6 +20,8 @@ var uuid: String
static func from_dict(data: Dictionary) -> SoundtrackWithSongList: static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
var result: SoundtrackWithSongList = SoundtrackWithSongList.new() var result: SoundtrackWithSongList = SoundtrackWithSongList.new()
result.added = ApiJson.get_string(data, "added", "") result.added = ApiJson.get_string(data, "added", "")
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
result.last_played = ApiJson.get_string(data, "last_played", "") result.last_played = ApiJson.get_string(data, "last_played", "")
result.name = ApiJson.get_string(data, "name", "") result.name = ApiJson.get_string(data, "name", "")
for item: Variant in ApiJson.get_array(data, "songs"): for item: Variant in ApiJson.get_array(data, "songs"):
@@ -30,6 +36,8 @@ static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
return { return {
"added": added, "added": added,
"alternative_names": alternative_names,
"cover_modified": cover_modified,
"last_played": last_played, "last_played": last_played,
"name": name, "name": name,
"songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()), "songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()),
@@ -4,6 +4,10 @@ class_name SoundtrackWithSongs
extends RefCounted extends RefCounted
## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API. ## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API.
## Other names the soundtrack is known by
var alternative_names: Array[String]
## When the cover last changed, in Unix seconds. Missing when there is no cover.
var cover_modified: int
var songs: Array[SongInfoForStats] var songs: Array[SongInfoForStats]
var soundtrack_id: int var soundtrack_id: int
var soundtrack_last_played: String var soundtrack_last_played: String
@@ -15,6 +19,8 @@ var soundtrack_uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults. ## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SoundtrackWithSongs: static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
var result: SoundtrackWithSongs = SoundtrackWithSongs.new() var result: SoundtrackWithSongs = SoundtrackWithSongs.new()
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
for item: Variant in ApiJson.get_array(data, "songs"): for item: Variant in ApiJson.get_array(data, "songs"):
if item is Dictionary: if item is Dictionary:
result.songs.append(SongInfoForStats.from_dict(item)) result.songs.append(SongInfoForStats.from_dict(item))
@@ -29,6 +35,8 @@ static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
## Returns the fields under their JSON names, ready for [method JSON.stringify]. ## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
return { return {
"alternative_names": alternative_names,
"cover_modified": cover_modified,
"songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()), "songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()),
"soundtrack_id": soundtrack_id, "soundtrack_id": soundtrack_id,
"soundtrack_last_played": soundtrack_last_played, "soundtrack_last_played": soundtrack_last_played,
+1
View File
@@ -10,6 +10,7 @@ var soundtracks_changed_content: Array[String]
var soundtracks_changed_title: Dictionary[String, String] var soundtracks_changed_title: Dictionary[String, String]
var soundtracks_re_added: Array[String] var soundtracks_re_added: Array[String]
var soundtracks_removed: Array[String] var soundtracks_removed: Array[String]
## How long the sync took, as minutes and seconds, e.g. "02:15"
var total_time: String var total_time: String
+8 -7
View File
@@ -18,17 +18,21 @@ func _init(owner: Node) -> void:
## Requests [param url] and calls [param on_complete] with ## Requests [param url] and calls [param on_complete] with
## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code], ## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code],
## the headers as [code]"Name: value"[/code] lines. Connection errors and timeouts are ## the headers as [code]"Name: value"[/code] lines. Connection errors and timeouts are
## reported with response code 0 and no headers. [param headers] are sent as-is; a ## reported with response code 0 and no headers. [param headers], which name the
## [param body] is sent as JSON. ## [param body]'s type, are sent as-is. Without [param follow_redirects] a redirect is
## answered as it is, with its [code]Location[/code] header.
func request( func request(
url: String, url: String,
method: int, method: int,
on_complete: Callable, on_complete: Callable,
headers: PackedStringArray = PackedStringArray(), headers: PackedStringArray = PackedStringArray(),
body: String = "", body: PackedByteArray = PackedByteArray(),
follow_redirects: bool = true,
) -> void: ) -> void:
var http_request: HTTPRequest = HTTPRequest.new() var http_request: HTTPRequest = HTTPRequest.new()
http_request.timeout = timeout_seconds http_request.timeout = timeout_seconds
if !follow_redirects:
http_request.max_redirects = 0
_owner.add_child(http_request) _owner.add_child(http_request)
http_request.request_completed.connect( http_request.request_completed.connect(
func( func(
@@ -43,10 +47,7 @@ func request(
return return
on_complete.call(response_code, response_body, response_headers) on_complete.call(response_code, response_body, response_headers)
) )
var all_headers: PackedStringArray = headers.duplicate() var error: int = http_request.request_raw(url, headers, method, body)
if method == HTTPClient.METHOD_PUT || !body.is_empty():
all_headers.append("Content-Type: application/json")
var error: int = http_request.request(url, all_headers, method, body)
if error != OK: if error != OK:
http_request.queue_free() http_request.queue_free()
push_error("Could not send request to %s (error %d)" % [url, error]) push_error("Could not send request to %s (error %d)" % [url, error])
+144 -36
View File
@@ -19,7 +19,7 @@ extends Node
## JSON objects are handed to [code]on_done[/code] as the classes generated from the ## JSON objects are handed to [code]on_done[/code] as the classes generated from the
## server's OpenAPI spec (see [code]server/generated/[/code]), lists of them as typed ## server's OpenAPI spec (see [code]server/generated/[/code]), lists of them as typed
## Arrays. [code]on_failed[/code] is called with no arguments when a request fails, ## Arrays. [code]on_failed[/code] is called with no arguments when a request fails,
## including when no token could be had. ## including when no token could be had; [member last_error] then has the server's reason.
## Path of the API on the server, added between [code]Settings.default_path[/code] and ## Path of the API on the server, added between [code]Settings.default_path[/code] and
## each endpoint. ## each endpoint.
@@ -28,9 +28,20 @@ const API_PATH: String = "/api/v1"
const CLIENT_TYPE: String = "godot" const CLIENT_TYPE: String = "godot"
## Header with the ID of the client's play queue, in requests and responses. ## Header with the ID of the client's play queue, in requests and responses.
const QUEUE_HEADER: String = "X-Queue-Id" const QUEUE_HEADER: String = "X-Queue-Id"
## The type of the JSON bodies sent.
const JSON_TYPE: String = "application/json"
## The files of a MusicPlayer release that can be downloaded: the Windows game, the Linux
## game, and the Linux package with an installer.
const DOWNLOAD_ASSETS: Array[String] = ["windows", "linux", "linux-package"]
## Where the cover of a soundtrack can be picked from, by the game's or anime's ID there.
const COVER_SOURCES: Array[String] = ["steamgriddb", "anilist"]
## Sends the actual HTTP requests. Swap it out in tests. ## Sends the actual HTTP requests. Swap it out in tests.
var transport: HttpTransport var transport: HttpTransport
## The server's message for the request that failed last, e.g. [code]"covers are
## disabled"[/code], or empty when it gave none. Read it in an [code]on_failed[/code]
## callback, before another request can fail.
var last_error: String = ""
var _token: String = "" var _token: String = ""
# The server the token is for; switching server in the settings needs a new token # The server the token is for; switching server in the settings needs a new token
@@ -51,17 +62,22 @@ func fetch_song_list(on_done: Callable, on_failed: Callable = Callable()) -> voi
get_json_list("/music/list", SongInfo, on_done, on_failed) get_json_list("/music/list", SongInfo, on_done, on_failed)
## Downloads a random song as MP3 bytes. With [param low_played] the server prefers ## Downloads a random song as MP3 bytes, picked the way [param mode] (a
## soundtracks that have been played the least. ## [code]Settings.RandomMode[/code]) says: a random soundtrack and then one of its songs,
func fetch_random_song( ## the same but preferring soundtracks played less than average, or every song with the
low_played: bool, on_done: Callable, on_failed: Callable = Callable()) -> void: ## same chance.
get_bytes("/music/rand/low" if low_played else "/music/rand", on_done, on_failed) func fetch_random_song(mode: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes(random_song_path(mode), on_done, on_failed)
## Downloads a random song as MP3 bytes, picked the way the server did before low played ## The endpoint for random songs picked the way [param mode] says.
## mode existed: any soundtrack is as likely as any other. static func random_song_path(mode: int) -> String:
func fetch_random_song_classic(on_done: Callable, on_failed: Callable = Callable()) -> void: match mode:
get_bytes("/music/rand/classic", on_done, on_failed) Settings.RandomMode.LOW_PLAYED:
return "/music/rand/low"
Settings.RandomMode.CLASSIC:
return "/music/rand/classic"
return "/music/rand"
## Adds the most recently fetched random song to the server playlist. ## Adds the most recently fetched random song to the server playlist.
@@ -126,6 +142,61 @@ func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> v
get_json_list("/soundtracks", Soundtrack, on_done, on_failed) get_json_list("/soundtracks", Soundtrack, on_done, on_failed)
## Downloads the cover picture of the soundtrack with [param uuid] as bytes (JPEG or
## PNG). The server answers 404 while it has no cover for it.
func fetch_soundtrack_cover(
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes(_cover_path(uuid), on_done, on_failed)
## Stores [param image], a JPEG, PNG or WebP picture of [param content_type] such as
## [code]"image/png"[/code], as the cover of the soundtrack with [param uuid]. The
## server's automatic search never replaces it.
func upload_soundtrack_cover(uuid: String, image: PackedByteArray, content_type: String,
on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(_cover_path(uuid), HTTPClient.METHOD_PUT, _call_ignoring_body.bind(on_done),
on_failed, true, image, content_type)
## Makes the cover of the game or anime with [param id] on [param source], one of
## [constant COVER_SOURCES], the cover of the soundtrack with [param uuid]. The server
## downloads it before answering.
func set_soundtrack_cover_source(uuid: String, source: String, id: String,
on_done: Callable, on_failed: Callable = Callable()) -> void:
var request: CoverSource = CoverSource.new()
request.source = source
request.id = id
_request(_cover_path(uuid) + "/source", HTTPClient.METHOD_PUT,
_call_ignoring_body.bind(on_done), on_failed, true,
JSON.stringify(request.to_dict()).to_utf8_buffer())
## Deletes the cover of the soundtrack with [param uuid], also one set by hand, so the
## server's automatic search looks for a new one.
func delete_soundtrack_cover(
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(_cover_path(uuid), HTTPClient.METHOD_DELETE, _call_ignoring_body.bind(on_done),
on_failed)
## Replaces the alternative names of the soundtrack with [param uuid] with
## [param names]. The server trims them, drops duplicates and the soundtrack's own name,
## and calls [param on_done] with the updated [Soundtrack].
func set_alternative_names(uuid: String, names: Array[String], on_done: Callable,
on_failed: Callable = Callable()) -> void:
var request: AlternativeNames = AlternativeNames.new()
request.names = names
var path: String = "/soundtracks/%s/alternative-names" % uuid.uri_encode()
var saved: Callable = func saved(data: Variant) -> void:
if !(data is Dictionary):
push_warning("Expected a JSON object from " + path)
_call_if_valid(on_failed)
return
on_done.call(Soundtrack.from_dict(data))
_request(path, HTTPClient.METHOD_PUT, _parse_json.bind(path, saved, on_failed), on_failed,
true, JSON.stringify(request.to_dict()).to_utf8_buffer())
## Fetches a soundtrack and its songs as a [SoundtrackWithSongList]. ## Fetches a soundtrack and its songs as a [SoundtrackWithSongList].
func fetch_soundtrack( func fetch_soundtrack(
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void: uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
@@ -278,14 +349,14 @@ func fetch_client_assets(on_done: Callable, on_failed: Callable = Callable()) ->
get_json("/download/list", on_done, on_failed) get_json("/download/list", on_done, on_failed)
## Downloads the newest MusicPlayer for Windows. ## Calls [param on_done] with the link to download the newest MusicPlayer file
func download_client_windows(on_done: Callable, on_failed: Callable = Callable()) -> void: ## [param asset] from, one of [constant DOWNLOAD_ASSETS]. The server answers with a
get_bytes("/download/windows", on_done, on_failed) ## redirect to the release on Gitea, which is not followed: the link needs no token, so
## the browser can open it.
func fetch_client_download_url(
## Downloads the newest MusicPlayer for Linux. asset: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
func download_client_linux(on_done: Callable, on_failed: Callable = Callable()) -> void: _request("/download/" + asset, HTTPClient.METHOD_GET, on_done, on_failed, true,
get_bytes("/download/linux", on_done, on_failed) PackedByteArray(), JSON_TYPE, false)
# Tokens # Tokens
@@ -323,14 +394,9 @@ func queue_id() -> String:
# Keeps the queue ID in [param headers] from [param server] when it is new # Keeps the queue ID in [param headers] from [param server] when it is new
func _remember_queue_id(server: String, headers: PackedStringArray) -> void: func _remember_queue_id(server: String, headers: PackedStringArray) -> void:
var prefix: String = QUEUE_HEADER.to_lower() + ":" var id: String = header_value(headers, QUEUE_HEADER)
for header: String in headers:
if !header.to_lower().begins_with(prefix):
continue
var id: String = header.substr(prefix.length()).strip_edges()
if !id.is_empty() && Settings.queue_ids.get(server, "") != id: if !id.is_empty() && Settings.queue_ids.get(server, "") != id:
Settings.set_queue_id(server, id) Settings.set_queue_id(server, id)
return
# Generic requests # Generic requests
@@ -414,24 +480,36 @@ func _parse_json(
# Sends the request with the token, and once more with a new token if the server # Sends the request with the token, and once more with a new token if the server
# answers 401 # answers 401. A [param body] of the type [param content_type] is sent with it. Without
# [param follow_redirects], a redirect is a success and [param on_success] gets where it
# points to.
func _request( func _request(
path: String, path: String,
method: int, method: int,
on_success: Callable, on_success: Callable,
on_failed: Callable, on_failed: Callable,
may_retry: bool = true, may_retry: bool = true,
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
follow_redirects: bool = true,
) -> void: ) -> void:
_with_token(func(token: String) -> void: _with_token(func(token: String) -> void:
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void: _send(path, method, token, func(response_code: int, response: PackedByteArray,
headers: PackedStringArray) -> void:
if response_code == 401 && may_retry: if response_code == 401 && may_retry:
GameLog.debug("Token refused by the server, getting a new one") GameLog.debug("Token refused by the server, getting a new one")
if _token == token: if _token == token:
clear_token() clear_token()
_request(path, method, on_success, on_failed, false) _request(path, method, on_success, on_failed, false, body, content_type,
follow_redirects)
return return
_handle_response(path, response_code, body, on_success, on_failed) var location: String = header_value(headers, "Location")
) if !follow_redirects && response_code >= 300 && response_code < 400 \
&& !location.is_empty():
on_success.call(location)
return
_handle_response(path, response_code, response, on_success, on_failed)
, body, content_type, follow_redirects)
, on_failed) , on_failed)
@@ -439,18 +517,27 @@ func _request(
func _send_once( func _send_once(
path: String, method: int, token: String, on_success: Callable, on_failed: Callable, path: String, method: int, token: String, on_success: Callable, on_failed: Callable,
) -> void: ) -> void:
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void: _send(path, method, token, func(response_code: int, body: PackedByteArray,
_headers: PackedStringArray) -> void:
_handle_response(path, response_code, body, on_success, on_failed) _handle_response(path, response_code, body, on_success, on_failed)
) )
func _send( func _send(
path: String, method: int, token: String, on_complete: Callable, body: String = "", path: String,
method: int,
token: String,
on_complete: Callable,
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
follow_redirects: bool = true,
) -> void: ) -> void:
var server: String = Settings.default_path var server: String = Settings.default_path
var headers: PackedStringArray = [] var headers: PackedStringArray = []
if !token.is_empty(): if !token.is_empty():
headers.append("Authorization: Bearer " + token) headers.append("Authorization: Bearer " + token)
if !body.is_empty() || method == HTTPClient.METHOD_PUT:
headers.append("Content-Type: " + content_type)
var queue: String = Settings.queue_ids.get(server, "") var queue: String = Settings.queue_ids.get(server, "")
if !queue.is_empty(): if !queue.is_empty():
headers.append("%s: %s" % [QUEUE_HEADER, queue]) headers.append("%s: %s" % [QUEUE_HEADER, queue])
@@ -458,8 +545,8 @@ func _send(
func(response_code: int, response_body: PackedByteArray, func(response_code: int, response_body: PackedByteArray,
response_headers: PackedStringArray) -> void: response_headers: PackedStringArray) -> void:
_remember_queue_id(server, response_headers) _remember_queue_id(server, response_headers)
on_complete.call(response_code, response_body) on_complete.call(response_code, response_body, response_headers)
, headers, body) , headers, body, follow_redirects)
func _handle_response( func _handle_response(
@@ -469,7 +556,9 @@ func _handle_response(
on_success: Callable, on_success: Callable,
on_failed: Callable, on_failed: Callable,
) -> void: ) -> void:
if response_code != 200: # Requests that change something may answer 204 No Content
if response_code < 200 || response_code >= 300:
last_error = _error_message(body).trim_prefix(": ")
push_warning("Request to %s failed with response code %d%s" push_warning("Request to %s failed with response code %d%s"
% [path, response_code, _error_message(body)]) % [path, response_code, _error_message(body)])
_call_if_valid(on_failed) _call_if_valid(on_failed)
@@ -496,7 +585,7 @@ func _request_token() -> void:
token_request.client_type = CLIENT_TYPE token_request.client_type = CLIENT_TYPE
var server: String = Settings.default_path var server: String = Settings.default_path
_send("/token", HTTPClient.METHOD_POST, "", _send("/token", HTTPClient.METHOD_POST, "",
func(response_code: int, body: PackedByteArray) -> void: func(response_code: int, body: PackedByteArray, _headers: PackedStringArray) -> void:
var waiters: Array[Array] = _token_waiters.duplicate() var waiters: Array[Array] = _token_waiters.duplicate()
_token_waiters.clear() _token_waiters.clear()
var token: String = _read_token(response_code, body) var token: String = _read_token(response_code, body)
@@ -508,7 +597,7 @@ func _request_token() -> void:
_token_server = server _token_server = server
for waiter: Array in waiters: for waiter: Array in waiters:
waiter[0].call(token) waiter[0].call(token)
, JSON.stringify(token_request.to_dict())) , JSON.stringify(token_request.to_dict()).to_utf8_buffer())
func _read_token(response_code: int, body: PackedByteArray) -> String: func _read_token(response_code: int, body: PackedByteArray) -> String:
@@ -537,6 +626,25 @@ static func _parse_or_null(body: PackedByteArray) -> Variant:
return json.data if json.parse(body.get_string_from_utf8()) == OK else null return json.data if json.parse(body.get_string_from_utf8()) == OK else null
## The value of the header [param header_name] in [param headers], given as
## [code]"Name: value"[/code] lines, or empty when it isn't there. Names are matched
## without regard to case.
static func header_value(headers: PackedStringArray, header_name: String) -> String:
for header: String in headers:
var colon: int = header.find(":")
if colon > 0 && header.left(colon).strip_edges().to_lower() == header_name.to_lower():
return header.substr(colon + 1).strip_edges()
return ""
static func _cover_path(uuid: String) -> String:
return "/soundtracks/%s/cover" % uuid.uri_encode()
static func _call_ignoring_body(_body: PackedByteArray, callback: Callable) -> void:
_call_if_valid(callback)
static func _call_if_valid(callback: Callable) -> void: static func _call_if_valid(callback: Callable) -> void:
if callback.is_valid(): if callback.is_valid():
callback.call() callback.call()
+659
View File
@@ -17,6 +17,23 @@
], ],
"type": "object" "type": "object"
}, },
"AlternativeNames": {
"additionalProperties": false,
"properties": {
"names": {
"description": "The new list of alternative names. Replaces the old list; send [] to remove them all.",
"items": {
"type": "string"
},
"maxItems": 50,
"type": "array"
}
},
"required": [
"names"
],
"type": "object"
},
"CharacterDetails": { "CharacterDetails": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -36,6 +53,30 @@
], ],
"type": "object" "type": "object"
}, },
"CoverSource": {
"additionalProperties": false,
"properties": {
"id": {
"description": "The game's ID on SteamGridDB, or the anime's ID on AniList",
"maxLength": 50,
"minLength": 1,
"type": "string"
},
"source": {
"description": "Where the cover comes from",
"enum": [
"steamgriddb",
"anilist"
],
"type": "string"
}
},
"required": [
"source",
"id"
],
"type": "object"
},
"ProgressResponse": { "ProgressResponse": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -43,6 +84,7 @@
"type": "string" "type": "string"
}, },
"time_spent": { "time_spent": {
"description": "How long the sync has been running, as minutes and seconds, e.g. \"02:15\"",
"type": "string" "type": "string"
} }
}, },
@@ -175,6 +217,18 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"alternative_names": {
"description": "Other names the soundtrack is known by",
"items": {
"type": "string"
},
"type": "array"
},
"cover_modified": {
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
"format": "int64",
"type": "integer"
},
"last_played": { "last_played": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
@@ -193,6 +247,7 @@
"required": [ "required": [
"uuid", "uuid",
"name", "name",
"alternative_names",
"times_played", "times_played",
"added" "added"
], ],
@@ -205,6 +260,18 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"alternative_names": {
"description": "Other names the soundtrack is known by",
"items": {
"type": "string"
},
"type": "array"
},
"cover_modified": {
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
"format": "int64",
"type": "integer"
},
"last_played": { "last_played": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
@@ -230,6 +297,7 @@
"songs", "songs",
"uuid", "uuid",
"name", "name",
"alternative_names",
"times_played", "times_played",
"added" "added"
], ],
@@ -238,6 +306,18 @@
"SoundtrackWithSongs": { "SoundtrackWithSongs": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"alternative_names": {
"description": "Other names the soundtrack is known by",
"items": {
"type": "string"
},
"type": "array"
},
"cover_modified": {
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
"format": "int64",
"type": "integer"
},
"songs": { "songs": {
"items": { "items": {
"$ref": "#/components/schemas/SongInfoForStats" "$ref": "#/components/schemas/SongInfoForStats"
@@ -267,6 +347,7 @@
"soundtrack_id", "soundtrack_id",
"soundtrack_uuid", "soundtrack_uuid",
"soundtrack_name", "soundtrack_name",
"alternative_names",
"soundtrack_played", "soundtrack_played",
"songs" "songs"
], ],
@@ -367,6 +448,7 @@
"type": "array" "type": "array"
}, },
"total_time": { "total_time": {
"description": "How long the sync took, as minutes and seconds, e.g. \"02:15\"",
"type": "string" "type": "string"
} }
}, },
@@ -805,6 +887,74 @@
] ]
} }
}, },
"/api/v1/download/linux-package": {
"get": {
"description": "Redirects to the download URL of the latest MusicPlayer release for Linux (package with installer).",
"operationId": "download-client-linux-package",
"responses": {
"302": {
"description": "Found",
"headers": {
"Location": {
"schema": {
"description": "Download URL of the asset",
"type": "string"
}
}
}
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Gateway"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Download the latest client for Linux (package with installer)",
"tags": [
"download"
]
}
},
"/api/v1/download/list": { "/api/v1/download/list": {
"get": { "get": {
"description": "Returns the file names of the latest MusicPlayer release.", "description": "Returns the file names of the latest MusicPlayer release.",
@@ -2663,6 +2813,515 @@
] ]
} }
}, },
"/api/v1/soundtracks/{uuid}/alternative-names": {
"put": {
"description": "Replaces the soundtrack's alternative names, which search and the cover search also use. Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.",
"operationId": "set-alternative-names",
"parameters": [
{
"description": "UUID of the soundtrack",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack",
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlternativeNames"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Soundtrack"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Set alternative names",
"tags": [
"library"
]
}
},
"/api/v1/soundtracks/{uuid}/cover": {
"delete": {
"description": "Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.",
"operationId": "delete-soundtrack-cover",
"parameters": [
{
"description": "UUID of the soundtrack or song",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack or song",
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Service Unavailable"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Remove the cover",
"tags": [
"covers"
]
},
"get": {
"description": "Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.",
"operationId": "get-soundtrack-cover",
"parameters": [
{
"description": "UUID of the soundtrack or song",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack or song",
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"image/jpeg": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/png": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/webp": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
}
},
"description": "The cover image"
},
"206": {
"content": {
"image/jpeg": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/png": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/webp": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
}
},
"description": "Part of the file, for a Range request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Soundtrack cover",
"tags": [
"covers"
]
},
"put": {
"description": "Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.",
"operationId": "upload-soundtrack-cover",
"parameters": [
{
"description": "UUID of the soundtrack",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack",
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"image/*": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Service Unavailable"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Upload a cover",
"tags": [
"covers"
]
}
},
"/api/v1/soundtracks/{uuid}/cover/source": {
"put": {
"description": "Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.",
"operationId": "set-soundtrack-cover-source",
"parameters": [
{
"description": "UUID of the soundtrack",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack",
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CoverSource"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Gateway"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Service Unavailable"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Pick a cover",
"tags": [
"covers"
]
}
},
"/api/v1/statistics/songs/least-played": { "/api/v1/statistics/songs/least-played": {
"get": { "get": {
"description": "Returns the least played songs with their soundtrack.", "description": "Returns the least played songs with their soundtrack.",
+47 -4
View File
@@ -12,9 +12,52 @@ script = ExtResource("card_script")
layout_mode = 2 layout_mode = 2
mouse_filter = 2 mouse_filter = 2
[node name="NameLabel" type="Label" parent="VBox" unique_id=1211000003] [node name="Header" type="HBoxContainer" parent="VBox"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 10
[node name="CoverFrame" type="Panel" parent="VBox/Header"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(0, 62) clip_children = 2
custom_minimum_size = Vector2(68, 102)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
[node name="CoverPlaceholder" type="Label" parent="VBox/Header/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_type_variation = &"TextLarge"
horizontal_alignment = 1
vertical_alignment = 1
[node name="CoverPicture" type="TextureRect" parent="VBox/Header/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Info" type="VBoxContainer" parent="VBox/Header"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="NameLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000003]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 50)
layout_mode = 2 layout_mode = 2
mouse_filter = 2 mouse_filter = 2
theme_type_variation = &"TextLarge" theme_type_variation = &"TextLarge"
@@ -22,12 +65,12 @@ autowrap_mode = 3
text_overrun_behavior = 3 text_overrun_behavior = 3
max_lines_visible = 2 max_lines_visible = 2
[node name="PlayedLabel" type="Label" parent="VBox" unique_id=1211000004] [node name="PlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000004]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
mouse_filter = 2 mouse_filter = 2
[node name="LastPlayedLabel" type="Label" parent="VBox" unique_id=1211000005] [node name="LastPlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000005]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
mouse_filter = 2 mouse_filter = 2
+51
View File
@@ -4,6 +4,9 @@ extends PanelContainer
## played, and its most and least played songs. Songs never played are not listed, and ## played, and its most and least played songs. Songs never played are not listed, and
## the least played are only shown when there are enough played songs to not repeat ## the least played are only shown when there are enough played songs to not repeat
## any. Clicking it asks for all its songs. ## any. Clicking it asks for all its songs.
##
## Its cover shows on the left once [method load_cover] has fetched it; until then, and
## when the soundtrack has no cover, the soundtrack's initials stand in for it.
## Emitted when the card is clicked. ## Emitted when the card is clicked.
signal pressed(soundtrack: SoundtrackWithSongs) signal pressed(soundtrack: SoundtrackWithSongs)
@@ -15,6 +18,11 @@ const SONGS_SHOWN: int = 3
## The soundtrack the card shows. ## The soundtrack the card shows.
var soundtrack: SoundtrackWithSongs var soundtrack: SoundtrackWithSongs
# The cover time asked for, so a cover is only asked for again when it changed
var _cover_asked: int = 0
@onready var cover_picture: TextureRect = %CoverPicture
@onready var cover_placeholder: Label = %CoverPlaceholder
@onready var name_label: Label = %NameLabel @onready var name_label: Label = %NameLabel
@onready var played_label: Label = %PlayedLabel @onready var played_label: Label = %PlayedLabel
@onready var last_played_label: Label = %LastPlayedLabel @onready var last_played_label: Label = %LastPlayedLabel
@@ -32,7 +40,14 @@ func _ready() -> void:
## Shows [param data] on the card. ## Shows [param data] on the card.
func show_soundtrack(data: SoundtrackWithSongs) -> void: func show_soundtrack(data: SoundtrackWithSongs) -> void:
var cover_changed: bool = soundtrack == null \
|| soundtrack.soundtrack_uuid != data.soundtrack_uuid \
|| soundtrack.cover_modified != data.cover_modified
soundtrack = data soundtrack = data
if cover_changed:
show_cover(null)
_cover_asked = 0
cover_placeholder.text = initials(data.soundtrack_name)
name_label.text = data.soundtrack_name name_label.text = data.soundtrack_name
tooltip_text = data.soundtrack_name tooltip_text = data.soundtrack_name
played_label.text = played_text(data.soundtrack_played) played_label.text = played_text(data.soundtrack_played)
@@ -55,6 +70,42 @@ func show_soundtrack(data: SoundtrackWithSongs) -> void:
_fill_song_list(least_played_list, least_played) _fill_song_list(least_played_list, least_played)
## Asks [param covers] for the soundtrack's cover, unless it already has, and shows it
## when it comes. Soundtracks without a cover keep their initials.
func load_cover(covers: CoverCache) -> void:
if soundtrack == null || soundtrack.cover_modified <= 0 \
|| _cover_asked == soundtrack.cover_modified:
return
_cover_asked = soundtrack.cover_modified
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified, show_cover)
## Takes the soundtrack's new cover time [param modified], 0 when its cover was removed,
## and shows the initials until [method load_cover] fetches the new cover.
func set_cover_time(modified: int) -> void:
soundtrack.cover_modified = modified
show_cover(null)
_cover_asked = 0
## Shows [param cover], or the initials when it is null.
func show_cover(cover: Image) -> void:
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
cover_placeholder.visible = cover == null
## Up to two initials of [param soundtrack_name], e.g. "FF" for "Final Fantasy VI".
static func initials(soundtrack_name: String) -> String:
var letters: String = ""
for word: String in soundtrack_name.split(" ", false):
var first: String = word.left(1)
if first.to_upper() != first.to_lower():
letters += first.to_upper()
if letters.length() == 2:
break
return letters if !letters.is_empty() else soundtrack_name.left(1)
## "Played 3 times", or "Never played" for [code]0[/code]. ## "Played 3 times", or "Never played" for [code]0[/code].
static func played_text(times_played: int) -> String: static func played_text(times_played: int) -> String:
if times_played == 0: if times_played == 0:
+63 -3
View File
@@ -5,6 +5,9 @@ extends Panel
## cards filtered on whether the soundtrack has been played. ## cards filtered on whether the soundtrack has been played.
## ##
## Shown instead of the quiz when the Statistics tab is chosen. ## Shown instead of the quiz when the Statistics tab is chosen.
##
## Covers are loaded for the cards on screen as they scroll into view, a few at a time,
## not for every soundtrack when the tab opens. See [CoverCache].
## Emitted when a card is clicked, to show all songs of [param soundtrack]. ## Emitted when a card is clicked, to show all songs of [param soundtrack].
signal soundtrack_selected(soundtrack: SoundtrackWithSongs) signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
@@ -19,12 +22,18 @@ enum ShowFilter { ALL, PLAYED, NEVER_PLAYED }
enum Mode { SOUNDTRACKS, SONGS } enum Mode { SOUNDTRACKS, SONGS }
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn") const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
## How far outside the visible part cards still get their covers, so they are ready when
## scrolled to.
const COVER_MARGIN: float = 400.0
## The cards in the order they are shown, hidden ones included. ## The cards in the order they are shown, hidden ones included.
var cards: Array[SoundtrackCard] = [] var cards: Array[SoundtrackCard] = []
## Whether soundtracks or songs are shown. Change it with [method set_mode]. ## Whether soundtracks or songs are shown. Change it with [method set_mode].
var mode: Mode = Mode.SOUNDTRACKS var mode: Mode = Mode.SOUNDTRACKS
## The covers on disk, shared by the cards.
var covers: CoverCache = CoverCache.new()
var _search: SoundtrackSearch = SoundtrackSearch.new() var _search: SoundtrackSearch = SoundtrackSearch.new()
var _loading: bool = false var _loading: bool = false
# The soundtracks last loaded, by name # The soundtracks last loaded, by name
@@ -61,6 +70,11 @@ func _ready() -> void:
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort()) sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
retry_button.pressed.connect(refresh) retry_button.pressed.connect(refresh)
retry_button.visible = false retry_button.visible = false
# The cards have moved by the end of the frame
card_scroll.get_v_scroll_bar().value_changed.connect(
func(_value: float) -> void: load_covers_on_screen.call_deferred())
card_scroll.resized.connect(load_covers_on_screen)
visibility_changed.connect(load_covers_on_screen)
## Loads the statistics from the server. The cards already shown stay until the new ## Loads the statistics from the server. The cards already shown stay until the new
@@ -108,6 +122,7 @@ func apply_sort() -> void:
return _comes_before(a.soundtrack, b.soundtrack, order)) return _comes_before(a.soundtrack, b.soundtrack, order))
for i: int in cards.size(): for i: int in cards.size():
card_grid.move_child(cards[i], i) card_grid.move_child(cards[i], i)
load_covers_on_screen.call_deferred()
## Shows only the cards or songs that match the search text and the show menu. ## Shows only the cards or songs that match the search text and the show menu.
@@ -124,14 +139,20 @@ func apply_filter() -> void:
var filter: ShowFilter = show_option.selected as ShowFilter var filter: ShowFilter = show_option.selected as ShowFilter
var shown: int = 0 var shown: int = 0
for card: SoundtrackCard in cards: for card: SoundtrackCard in cards:
var was_visible: bool = card.visible
card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \ card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \
&& _passes_filter(card.soundtrack, filter) && _passes_filter(card.soundtrack, filter)
if card.visible: if card.visible:
shown += 1 shown += 1
# Cards that come back with the filter fade in
if !was_visible:
Anim.fade_in(card)
if shown == cards.size(): if shown == cards.size():
count_label.text = "%d soundtracks" % cards.size() count_label.text = "%d soundtracks" % cards.size()
else: else:
count_label.text = "%d of %d soundtracks" % [shown, cards.size()] count_label.text = "%d of %d soundtracks" % [shown, cards.size()]
# The grid has moved the cards by the end of the frame
load_covers_on_screen.call_deferred()
func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void: func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
@@ -145,19 +166,58 @@ func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
cards.append(card) cards.append(card)
while cards.size() > soundtracks.size(): while cards.size() > soundtracks.size():
cards.pop_back().free() cards.pop_back().free()
var names: Array[String] = []
_soundtracks.clear() _soundtracks.clear()
var cover_times: Dictionary = {}
for i: int in soundtracks.size(): for i: int in soundtracks.size():
cards[i].show_soundtrack(soundtracks[i]) cards[i].show_soundtrack(soundtracks[i])
names.append(soundtracks[i].soundtrack_name)
_soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i] _soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i]
_search.set_names(names) cover_times[soundtracks[i].soundtrack_uuid] = soundtracks[i].cover_modified
covers.keep_only(cover_times)
_update_search_names()
_songs_ready = false _songs_ready = false
apply_sort() apply_sort()
if mode == Mode.SOUNDTRACKS: if mode == Mode.SOUNDTRACKS:
apply_filter() apply_filter()
## Asks for the covers of the cards that are on screen, or nearly.
func load_covers_on_screen() -> void:
if !is_visible_in_tree() || mode != Mode.SOUNDTRACKS:
return
var view: Rect2 = card_scroll.get_global_rect().grow(COVER_MARGIN)
for card: SoundtrackCard in cards:
if card.visible && view.intersects(card.get_global_rect()):
card.load_cover(covers)
## Shows the new alternative names of the soundtrack [param updated] and finds it by
## them, without loading the statistics again.
func update_alternative_names(updated: Soundtrack) -> void:
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
if soundtrack.soundtrack_uuid == updated.uuid:
soundtrack.alternative_names = updated.alternative_names
_update_search_names()
apply_filter()
## Shows the new cover of the soundtrack [param uuid], with the cover time
## [param modified], or its initials when [param modified] is 0.
func update_cover(uuid: String, modified: int) -> void:
for card: SoundtrackCard in cards:
if card.soundtrack.soundtrack_uuid == uuid:
card.set_cover_time(modified)
load_covers_on_screen()
func _update_search_names() -> void:
var names: Array[String] = []
var alternatives: Dictionary = {}
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
names.append(soundtrack.soundtrack_name)
alternatives[soundtrack.soundtrack_name] = soundtrack.alternative_names
_search.set_names(names, alternatives)
# Typing in the song list waits for a pause, since searching every song takes a moment # Typing in the song list waits for a pause, since searching every song takes a moment
func _on_search_text_changed(_text: String) -> void: func _on_search_text_changed(_text: String) -> void:
if mode == Mode.SONGS: if mode == Mode.SONGS:
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+14420 -4456
View File
File diff suppressed because it is too large. Load diff
+35 -7
View File
@@ -25,6 +25,15 @@ var requested_paths: Array[String] = []
var requested_methods: Array[int] = [] var requested_methods: Array[int] = []
## Headers of each request, in the same order as [member requested_paths]. ## Headers of each request, in the same order as [member requested_paths].
var requested_headers: Array[PackedStringArray] = [] var requested_headers: Array[PackedStringArray] = []
## The body of each request as text, e.g. its JSON, empty for none, in the same order as
## [member requested_paths].
var requested_bodies: Array[String] = []
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
## [member requested_paths].
var requested_raw_bodies: Array[PackedByteArray] = []
## Whether each request followed redirects, in the same order as
## [member requested_paths].
var requested_follow_redirects: Array[bool] = []
## How many tokens have been asked for. ## How many tokens have been asked for.
var token_requests: int = 0 var token_requests: int = 0
## The JSON body of the last token request. ## The JSON body of the last token request.
@@ -61,6 +70,14 @@ func uninstall() -> void:
Settings.queue_ids = _previous_queue_ids Settings.queue_ids = _previous_queue_ids
## Answers requests to [param path] with a redirect to [param location], like the
## server's download routes. Requests that follow redirects get an empty 200 instead, as
## if they had followed it.
func respond_redirect(path: String, location: String) -> void:
_responses[path] = {"code": 302, "body": PackedByteArray(), "location": location}
_successes_left.erase(path)
## Answers requests to [param path] with [param data] encoded as JSON. ## Answers requests to [param path] with [param data] encoded as JSON.
func respond_json(path: String, data: Variant, response_code: int = 200) -> void: func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code) respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
@@ -107,30 +124,35 @@ func request(
method: int, method: int,
on_complete: Callable, on_complete: Callable,
headers: PackedStringArray = PackedStringArray(), headers: PackedStringArray = PackedStringArray(),
body: String = "", body: PackedByteArray = PackedByteArray(),
follow_redirects: bool = true,
) -> void: ) -> void:
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH) var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
if path == "/token" && method == HTTPClient.METHOD_POST: if path == "/token" && method == HTTPClient.METHOD_POST:
token_requests += 1 token_requests += 1
if _held_paths.has(path): if _held_paths.has(path):
_held_paths[path].append(_answer_token_request.bind(body, on_complete)) _held_paths[path].append(
_answer_token_request.bind(body.get_string_from_utf8(), on_complete))
return return
_answer_token_request(body, on_complete) _answer_token_request(body.get_string_from_utf8(), on_complete)
return return
requested_paths.append(path) requested_paths.append(path)
requested_methods.append(method) requested_methods.append(method)
requested_headers.append(headers) requested_headers.append(headers)
requested_bodies.append(body.get_string_from_utf8())
requested_raw_bodies.append(body)
requested_follow_redirects.append(follow_redirects)
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers): if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(), on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
PackedStringArray()) PackedStringArray())
return return
if _held_paths.has(path): if _held_paths.has(path):
_held_paths[path].append(_answer.bind(path, on_complete)) _held_paths[path].append(_answer.bind(path, on_complete, follow_redirects))
return return
_answer(path, on_complete) _answer(path, on_complete, follow_redirects)
func _answer(path: String, on_complete: Callable) -> void: func _answer(path: String, on_complete: Callable, follow_redirects: bool = true) -> void:
var headers: PackedStringArray = [] var headers: PackedStringArray = []
if !queue_id.is_empty() && path != "/token": if !queue_id.is_empty() && path != "/token":
headers.append("X-Queue-Id: " + queue_id) headers.append("X-Queue-Id: " + queue_id)
@@ -139,7 +161,13 @@ func _answer(path: String, on_complete: Callable) -> void:
on_complete.call(503, PackedByteArray(), headers) on_complete.call(503, PackedByteArray(), headers)
return return
_successes_left[path] -= 1 _successes_left[path] -= 1
if _responses.has(path): if _responses.has(path) && _responses[path].has("location"):
if follow_redirects:
on_complete.call(200, PackedByteArray(), headers)
else:
headers.append("Location: " + _responses[path].location)
on_complete.call(302, PackedByteArray(), headers)
elif _responses.has(path):
on_complete.call(_responses[path].code, _responses[path].body, headers) on_complete.call(_responses[path].code, _responses[path].body, headers)
else: else:
on_complete.call(404, PackedByteArray(), headers) on_complete.call(404, PackedByteArray(), headers)
+12 -3
View File
@@ -20,12 +20,18 @@ func before_test() -> void:
Playlist.first_song_played = false Playlist.first_song_played = false
Playlist.is_song_downloading = false Playlist.is_song_downloading = false
Settings.player_array.clear() Settings.player_array.clear()
Settings.music_folder = null
Settings.character_folder = ""
# Every test starts a new quiz, unless it saves one in _before_window_starts()
QuizSave.delete()
Settings.add_to_stats = true Settings.add_to_stats = true
Settings.hide_next_track = true Settings.hide_next_track = true
Settings.use_low_played_mode = false Settings.random_mode = Settings.RandomMode.NORMAL
Settings.winning_score = 20 Settings.winning_score = 20
# Otherwise the About popup opens at start, as for a new version # Otherwise the About popup opens at start, as for a new version
Settings.last_seen_version = Settings.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() _server = FakeHttpTransport.new().install()
var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3") var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
@@ -44,6 +50,8 @@ func before_test() -> void:
_runner = scene_runner("res://main/MainWindow.tscn") _runner = scene_runner("res://main/MainWindow.tscn")
_window = _runner.scene() _window = _runner.scene()
_quiz = _window.get_node("Quiz") _quiz = _window.get_node("Quiz")
# Rows of buttons are laid out by their containers at the end of the first frame
await _runner.simulate_frames(1)
## Override to change the fake server before the window starts and sends its first ## Override to change the fake server before the window starts and sends its first
@@ -55,6 +63,7 @@ func _before_window_starts() -> void:
func after_test() -> void: func after_test() -> void:
# Let nodes queued for deletion by the window be freed before orphans are counted # Let nodes queued for deletion by the window be freed before orphans are counted
await await_idle_frame() await await_idle_frame()
QuizSave.delete()
_server.uninstall() _server.uninstall()
_snapshot.restore() _snapshot.restore()
@@ -77,7 +86,7 @@ func _play_first_song() -> void:
## Adds a player through the name field, the same way the user does. ## Adds a player through the name field, the same way the user does.
func _add_player(player_name: String) -> PlayerObject: func _add_player(player_name: String) -> PlayerObject:
var name_field: TextEdit = _quiz.get_node( var name_field: LineEdit = _quiz.get_node(
"PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField") "PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField")
name_field.text = player_name name_field.text = player_name
_quiz.players_panel.add_player() _quiz.players_panel.add_player()
@@ -96,7 +105,7 @@ func _player_row(player_object: PlayerObject) -> PlayerRow:
## Removes a player with the remove button in its row. ## Removes a player with the remove button in its row.
func _remove_player(player_object: PlayerObject) -> void: func _remove_player(player_object: PlayerObject) -> void:
(_player_row(player_object).get_node("HBoxContainer/RemovePlayer") as BaseButton) \ (_player_row(player_object).get_node("%RemovePlayer") as BaseButton) \
.pressed.emit() .pressed.emit()
await _runner.simulate_frames(1) await _runner.simulate_frames(1)
+63 -6
View File
@@ -15,9 +15,10 @@ const SOUNDTRACKS: Array[String] = [
"Donkey Kong Country 2", "Lufia II", "Illusion of Gaia", "Donkey Kong Country 2", "Lufia II", "Illusion of Gaia",
] ]
const SONG_INFO: Dictionary = { const SONG_INFO: Dictionary = {
"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time", "SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "soundtrack-0",
"Song": "Corridors of Time",
} }
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:00:02"} const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:02"}
const SYNC_RESULT: Dictionary = { const SYNC_RESULT: Dictionary = {
"soundtracks_added": ["Terranigma", "Lufia II"], "soundtracks_added": ["Terranigma", "Lufia II"],
"soundtracks_re_added": [], "soundtracks_re_added": [],
@@ -25,7 +26,7 @@ const SYNC_RESULT: Dictionary = {
"soundtracks_changed_content": ["Chrono Trigger"], "soundtracks_changed_content": ["Chrono Trigger"],
"soundtracks_removed": ["Old Soundtrack"], "soundtracks_removed": ["Old Soundtrack"],
"catched_errors": [], "catched_errors": [],
"total_time": "00:00:03", "total_time": "00:03",
} }
## The server's version history, newest first. ## The server's version history, newest first.
@@ -48,6 +49,8 @@ const JAPANESE_SONGS: Array[String] = [
## The character pictures the fake server hands out. ## The character pictures the fake server hands out.
const CHARACTERS_DIR: String = "res://characters" const CHARACTERS_DIR: String = "res://characters"
## Small copies of covers from the MusicServer, 2:3 like all its covers.
const COVERS_DIR: String = "res://tests/data/covers"
## Popups in the order the tour opens them: the state name, then the popup's node. The ## Popups in the order the tour opens them: the state name, then the popup's node. The
## state names are what the popups were first called, so screenshots taken before they ## state names are what the popups were first called, so screenshots taken before they
@@ -91,6 +94,18 @@ static func make_server() -> FakeHttpTransport:
server.respond_bytes("/sync", PackedByteArray()) server.respond_bytes("/sync", PackedByteArray())
server.respond_json("/sync/progress", SYNC_PROGRESS) server.respond_json("/sync/progress", SYNC_PROGRESS)
server.respond_json("/statistics/soundtracks", soundtrack_statistics()) server.respond_json("/statistics/soundtracks", soundtrack_statistics())
# Real covers from the server, made smaller, for every other soundtrack
for i: int in range(0, SOUNDTRACKS.size(), 2):
server.respond_bytes("/soundtracks/soundtrack-%d/cover" % i,
FileAccess.get_file_as_bytes(COVERS_DIR.path_join("cover_%d.jpg" % (i >> 1))))
# The soundtrack list tells the quiz which soundtracks have covers
var soundtracks: Array[Dictionary] = []
for soundtrack: Dictionary in soundtrack_statistics():
soundtracks.append({"uuid": soundtrack.soundtrack_uuid,
"name": soundtrack.soundtrack_name,
"cover_modified": soundtrack.get("cover_modified", 0),
"alternative_names": soundtrack.get("alternative_names", [])})
server.respond_json("/soundtracks", soundtracks)
server.respond_json("/version/history", SERVER_HISTORY) server.respond_json("/version/history", SERVER_HISTORY)
return server return server
@@ -118,6 +133,11 @@ static func soundtrack_statistics() -> Array[Dictionary]:
} }
if played > 0: if played > 0:
soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1) soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1)
# Every other soundtrack has a cover, the rest show their initials
if i % 2 == 0:
soundtrack.cover_modified = 1700000000
if i == 0:
soundtrack.alternative_names = ["CT", "クロノ・トリガー"]
statistics.append(soundtrack) statistics.append(soundtrack)
return statistics return statistics
@@ -163,14 +183,20 @@ static func prepare_autoloads() -> void:
Playlist.first_song_played = false Playlist.first_song_played = false
Playlist.is_song_downloading = false Playlist.is_song_downloading = false
Settings.player_array.clear() Settings.player_array.clear()
Settings.music_folder = null
Settings.character_folder = ""
# A quiz saved by an earlier run would be continued
QuizSave.delete()
Settings.edit_players = false Settings.edit_players = false
Settings.currently_syncing = false Settings.currently_syncing = false
Settings.character_select_open = false Settings.character_select_open = false
Settings.add_to_stats = true Settings.add_to_stats = true
Settings.hide_next_track = true Settings.hide_next_track = true
Settings.use_low_played_mode = false Settings.random_mode = Settings.RandomMode.NORMAL
Settings.winning_score = 20 Settings.winning_score = 20
Settings.last_seen_version = Settings.version 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 # The inspiration list scrolls by itself, which would make every picture different
Settings.inspiration_list_speed = 0 Settings.inspiration_list_speed = 0
@@ -198,7 +224,8 @@ func run(on_state: Callable) -> void:
var picture: Image = Image.new() var picture: Image = Image.new()
picture.load_png_from_buffer( picture.load_png_from_buffer(
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_names()[0]))) FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_names()[0])))
_player_row(0).new_character_selected(ImageTexture.create_from_image(picture)) _player_row(0).new_character_selected(ImageTexture.create_from_image(picture),
character_names()[0])
_player_row(0).add_point() _player_row(0).add_point()
_player_row(0).add_point() _player_row(0).add_point()
_player_row(1).add_point() _player_row(1).add_point()
@@ -249,6 +276,27 @@ func run(on_state: Callable) -> void:
top_bar.show_latest_version("99.10.10") top_bar.show_latest_version("99.10.10")
await _settle() await _settle()
await on_state.call("update_available", null) await on_state.call("update_available", null)
var update_popup: UpdatePopup = _node("UpdatePopup")
var updater: Updater = update_popup.updater
updater.in_editor = false
updater.os_name = "Windows"
DirAccess.make_dir_recursive_absolute(Settings.config_dir())
updater.executable_path = Settings.config_dir().path_join("MusicPlayer.exe")
update_popup.show_update("99.10.10")
await _settle()
await on_state.call("popup_UpdateWindow", update_popup)
update_popup.download_progress.visible = true
update_popup._set_busy(true)
update_popup._show_status("")
update_popup._on_progress(31 << 20, 80 << 20)
await _settle()
await on_state.call("popup_UpdateWindow_downloading", update_popup)
updater.in_editor = true
update_popup.show_update("99.10.10")
await _settle()
await on_state.call("popup_UpdateWindow_browser_only", update_popup)
update_popup.close()
await _settle()
top_bar.show_latest_version("1.0.0") top_bar.show_latest_version("1.0.0")
await _settle() await _settle()
@@ -285,6 +333,15 @@ func run(on_state: Callable) -> void:
break break
await _settle() await _settle()
await on_state.call("popup_SoundtrackStats", stats_popup) await on_state.call("popup_SoundtrackStats", stats_popup)
stats_popup.edit_names()
stats_popup.new_name_field.text = "Chrono"
await _settle()
await on_state.call("popup_SoundtrackStats_names", stats_popup)
stats_popup.change_cover()
stats_popup.cover_id_field.text = "5248061"
await _settle()
await on_state.call("popup_SoundtrackStats_cover", stats_popup)
stats_popup.stop_editing()
stats_popup.close() stats_popup.close()
_main.show_tab(&"quiz") _main.show_tab(&"quiz")
await _settle() await _settle()
@@ -430,7 +487,7 @@ func _settle() -> void:
func _add_player(player_name: String) -> void: func _add_player(player_name: String) -> void:
(_node("PlayersPanel").get_node("VBoxContainer/AddPlayerContainer/PlayerNameField") \ (_node("PlayersPanel").get_node("VBoxContainer/AddPlayerContainer/PlayerNameField") \
as TextEdit).text = player_name as LineEdit).text = player_name
_call("add_player") _call("add_player")
+2 -2
View File
@@ -14,12 +14,12 @@ func _before_window_starts() -> void:
func before_test() -> void: func before_test() -> void:
super() await super()
_popup = _window.get_node("AboutPopup") _popup = _window.get_node("AboutPopup")
func _open_with_top_bar_button() -> void: func _open_with_top_bar_button() -> void:
await _click_at((_window.get_node("TopBar/AboutButton") as Control).get_global_rect() await _click_at((_window.get_node("TopBar/%AboutButton") as Control).get_global_rect()
.get_center()) .get_center())
+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/Titles/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
+192
View File
@@ -0,0 +1,192 @@
extends MainWindowTestSuite
## Characters from a folder on this computer: one picked in the settings, or the
## characters folder of the music folder played. Players pick again when the characters
## change.
var _settings: SettingsPopup
var _mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
func _before_window_starts() -> void:
var png: PackedByteArray = Image.create(8, 4, false, Image.FORMAT_RGB8).save_png_to_buffer()
_server.respond_json("/character/list", ["Crono.png", "Frog.png"])
_server.respond_bytes("/character?name=Crono.png", png)
_server.respond_bytes("/character?name=Frog.png", png)
func before_test() -> void:
await super()
_settings = _window.settings_popup
await _runner.simulate_frames(2)
func _folder(pictures: Array[String], songs: Array[String] = []) -> String:
var root: String = create_temp_dir("character_folders_%d" % randi())
for file_name: String in pictures:
var path: String = root.path_join(file_name)
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
FileAccess.open(path, FileAccess.WRITE).store_buffer(
Image.create(16, 8, false, Image.FORMAT_RGB8).save_png_to_buffer())
for file_name: String in songs:
FileAccess.open(root.path_join(file_name), FileAccess.WRITE).store_buffer(_mp3)
return root
func _card_names() -> Array[String]:
var names: Array[String] = []
for card: CharacterCard in _quiz.character_select_popup.character_grid.get_children():
if !card.is_queued_for_deletion():
names.append(card.character_name)
return names
func _pick(player: PlayerObject, character_name: String) -> void:
_player_row(player).change_character()
await _runner.simulate_frames(1)
for card: CharacterCard in _quiz.character_select_popup.character_grid.get_children():
if card.character_name == character_name:
await _click_at(card.get_global_rect().get_center())
return
fail("No card for " + character_name)
func test_server_characters_are_shown_at_first() -> void:
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
assert_str(_settings.characters_label.text).is_equal("From the server")
assert_bool(_settings.use_server_characters_button.visible).is_false()
func test_picking_a_folder_shows_its_characters() -> void:
var folder: String = _folder(["Santa.png", "Elf.jpg"])
_settings.use_character_folder(folder)
await _runner.simulate_frames(1)
assert_array(_card_names()).contains_exactly(["Elf.jpg", "Santa.png"])
assert_str(_settings.characters_label.text).is_equal(folder.get_file())
assert_bool(_settings.use_server_characters_button.visible).is_true()
func test_character_from_a_folder_is_picked_with_its_picture_and_name() -> void:
_settings.use_character_folder(_folder(["Santa.png"]))
var crono: PlayerObject = _add_player("Crono")
await _runner.simulate_frames(1)
await _pick(crono, "Santa.png")
assert_str(crono.character_name).is_equal("Santa.png")
assert_vector(crono.character.get_size()).is_equal(Vector2(16, 8))
func test_picking_a_folder_resets_the_players_characters() -> void:
var crono: PlayerObject = _add_player("Crono")
await _pick(crono, "Frog.png")
assert_str(crono.character_name).is_equal("Frog.png")
_settings.use_character_folder(_folder(["Santa.png"]))
await _runner.simulate_frames(1)
assert_str(crono.character_name).is_empty()
assert_str(crono.character.resource_path).is_equal(PlayerObject.NO_CHARACTER_PATH)
assert_object(_player_row(crono).character.texture_normal).is_same(crono.character)
func test_folder_without_pictures_is_not_used() -> void:
var crono: PlayerObject = _add_player("Crono")
await _pick(crono, "Frog.png")
_settings.use_character_folder(_folder([], ["01. Song.mp3"]))
assert_bool(_settings.characters_error_label.visible).is_true()
assert_str(_settings.characters_error_label.text).starts_with("No pictures found in")
assert_str(Settings.character_folder).is_empty()
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
assert_str(crono.character_name).is_equal("Frog.png")
func test_use_server_goes_back_to_the_server_characters() -> void:
_settings.use_character_folder(_folder(["Santa.png"]))
var crono: PlayerObject = _add_player("Crono")
await _pick(crono, "Santa.png")
_settings.use_server_characters()
await _runner.simulate_frames(1)
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
assert_str(_settings.characters_label.text).is_equal("From the server")
assert_bool(_settings.use_server_characters_button.visible).is_false()
assert_str(crono.character_name).is_empty()
func test_music_folder_with_characters_brings_its_own() -> void:
var folder: String = _folder(["characters/Santa.png", "characters/Elf.png"],
["01. Jingle Bells.mp3"])
var crono: PlayerObject = _add_player("Crono")
await _pick(crono, "Frog.png")
_settings.use_music_folder(folder)
await _runner.simulate_frames(2)
assert_array(_card_names()).contains_exactly(["Elf.png", "Santa.png"])
assert_str(_settings.characters_label.text).is_equal("From " + folder.get_file())
assert_str(crono.character_name).is_empty()
# A prepared quiz with characters is still a prepared quiz
assert_bool(Settings.is_prepared_quiz()).is_true()
func test_music_folder_without_characters_keeps_the_server_characters() -> void:
var crono: PlayerObject = _add_player("Crono")
await _pick(crono, "Frog.png")
_settings.use_music_folder(_folder([], ["01. Song.mp3"]))
await _runner.simulate_frames(2)
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
assert_str(crono.character_name).is_equal("Frog.png")
func test_leaving_a_music_folder_with_characters_goes_back_to_the_server() -> void:
_settings.use_music_folder(_folder(["characters/Santa.png"], ["01. Song.mp3"]))
await _runner.simulate_frames(2)
var crono: PlayerObject = _add_player("Crono")
await _pick(crono, "Santa.png")
_settings.use_server()
await _runner.simulate_frames(2)
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
assert_str(crono.character_name).is_empty()
func test_picked_folder_is_used_before_the_music_folders_characters() -> void:
var picked: String = _folder(["Snowman.png"])
_settings.use_character_folder(picked)
await _runner.simulate_frames(1)
_settings.use_music_folder(_folder(["characters/Santa.png"], ["01. Song.mp3"]))
await _runner.simulate_frames(2)
assert_array(_card_names()).contains_exactly(["Snowman.png"])
assert_str(_settings.characters_label.text).is_equal(picked.get_file())
func test_loading_the_same_characters_again_keeps_the_players_characters() -> void:
var crono: PlayerObject = _add_player("Crono")
await _pick(crono, "Frog.png")
# A sync reloads the characters from the same server
_quiz.on_sync_finished()
await _runner.simulate_frames(2)
assert_str(crono.character_name).is_equal("Frog.png")
func test_the_characters_folder_is_kept_for_this_session_only() -> void:
_settings.use_character_folder(_folder(["Santa.png"]))
Settings.save_settings()
var saved: ConfigFile = ConfigFile.new()
saved.load(Settings.saved_settings_path())
for section: String in saved.get_sections():
assert_bool(saved.has_section_key(section, "character_folder")).is_false()
@@ -0,0 +1 @@
uid://dcjuwndgpwled
@@ -21,10 +21,11 @@ func _before_window_starts() -> void:
func before_test() -> void: func before_test() -> void:
super() await super()
_picked.clear() _picked.clear()
_popup = _quiz.get_node("CharacterSelectPopup") _popup = _quiz.get_node("CharacterSelectPopup")
_popup.character_selected.connect(func(texture: Texture2D) -> void: _picked.append(texture)) _popup.character_selected.connect(
func(texture: Texture2D, _name: String) -> void: _picked.append(texture))
# Opened from the player's row, like clicking the player's picture # Opened from the player's row, like clicking the player's picture
_crono = _add_player("Crono") _crono = _add_player("Crono")
_player_row(_crono).change_character() _player_row(_crono).change_character()
+100
View File
@@ -0,0 +1,100 @@
extends MainWindowTestSuite
## Clear cache in the settings: deletes the downloaded character pictures, covers and
## songs, but not the songs the quiz uses, the settings or the saved quiz.
var _popup: SettingsPopup
func before_test() -> void:
# The cache is kept next to the settings, which the tests keep in a folder of their own
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("clear_cache_%d" % randi()))
await super()
_popup = _window.get_node("SettingsPopup")
func _write(path: String, size: int) -> void:
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
var bytes: PackedByteArray = PackedByteArray()
bytes.resize(size)
file.store_buffer(bytes)
file.close()
func _files_in(dir: String) -> PackedStringArray:
return DirAccess.get_files_at(dir) if DirAccess.dir_exists_absolute(dir) \
else PackedStringArray()
func _fill_cache() -> String:
_write(CharacterCache.folder().path_join("Crono.png"), 3000)
_write(CoverCache.folder().path_join("st-1"), 2000)
var old_song: String = Playlist.songs_folder().path_join("old.mp3")
_write(old_song, 5000)
return old_song
func test_shows_how_much_the_cache_takes() -> void:
_fill_cache()
_popup.open()
assert_str(_popup.cache_size_label.text).is_equal(
SettingsPopup.size_text(SettingsPopup.cache_size()))
assert_int(SettingsPopup.cache_size()).is_greater_equal(10000)
assert_bool(_popup.clear_cache_button.disabled).is_false()
func test_clear_cache_deletes_pictures_covers_and_old_songs() -> void:
var old_song: String = _fill_cache()
var playing: PackedStringArray = Playlist.song_files_in_use()
assert_array(playing).is_not_empty()
var lists_before: int = _server.count("/character/list")
_popup.open()
_popup.clear_cache()
assert_array(_files_in(CharacterCache.folder())).is_empty()
assert_array(_files_in(CoverCache.folder())).is_empty()
assert_bool(FileAccess.file_exists(old_song)).is_false()
# The quiz's songs stay, so they can still be played
for path: String in playing:
assert_bool(FileAccess.file_exists(path)).is_true()
assert_str(_popup.cache_size_label.text).starts_with("Cleared ")
assert_bool(_popup.clear_cache_button.disabled).is_true()
# The characters are loaded again at once
assert_int(_server.count("/character/list")).is_greater(lists_before)
func test_clear_cache_keeps_the_settings_and_the_saved_quiz() -> void:
Settings.save_settings()
_quiz.save_soon()
await _runner.simulate_frames(1)
_popup.open()
_popup.clear_cache()
assert_bool(FileAccess.file_exists(Settings.saved_settings_path())).is_true()
assert_bool(FileAccess.file_exists(QuizSave.path())).is_true()
func test_nothing_to_clear() -> void:
for dir: String in [CharacterCache.folder(), CoverCache.folder()]:
for file_name: String in _files_in(dir):
DirAccess.remove_absolute(dir.path_join(file_name))
Playlist.clear_song_files(Playlist.song_files_in_use())
_popup.open()
assert_str(_popup.cache_size_label.text).is_equal("Nothing")
assert_bool(_popup.clear_cache_button.disabled).is_true()
@warning_ignore("unused_parameter")
func test_size_text(bytes: int, text: String, test_parameters: Array = [
[0, "Nothing"],
[1, "1 KB"],
[350 * 1024, "350 KB"],
[int(14.2 * 1024 * 1024), "14.2 MB"],
]) -> void:
assert_str(SettingsPopup.size_text(bytes)).is_equal(text)
@@ -0,0 +1 @@
uid://da5wh1me3wncq
+414
View File
@@ -0,0 +1,414 @@
extends MainWindowTestSuite
## The quiz is saved after every change and goes on where it was when the game starts
## again: players, points, characters and songs, from the server or a music folder. A new
## game, a changed server playlist or a file that can't be read start over.
const SECOND_SONG: Dictionary = {"SongNo": 1, "Soundtrack": "Final Fantasy VI",
"Song": "Dancing Mad"}
var _mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
func _saved() -> QuizSave:
# Saving waits until the end of the frame
await _runner.simulate_frames(1)
return QuizSave.read()
## Closes the game and starts it again, like the user does: everything in memory is gone
## and only the files are left. The server answers with [param server_songs] as its
## playlist, the songs it handed out before.
func _restart_game(server_songs: Array = SONG_INFO) -> void:
await _runner.simulate_frames(1)
_window.free()
Settings.player_array.clear()
Settings.music_folder = null
Settings.character_folder = ""
Settings.add_to_stats = true
Playlist.song_object_array.clear()
Playlist.currently_playing_song = -1
Playlist.latest_played_song = 0
Playlist.game_start_song = 0
Playlist.first_song_played = false
Playlist.is_song_downloading = false
Playlist._song_files_written = 0
_server.respond_json("/music/list", server_songs)
_server.requested_paths.clear()
_runner = scene_runner("res://main/MainWindow.tscn")
_window = _runner.scene()
_quiz = _window.get_node("Quiz")
await _runner.simulate_frames(3)
# Crono and Marle play the first song; Crono gets the point and the answer is shown. The
# second song is downloaded ahead.
func _play_a_little() -> Array[PlayerObject]:
var crono: PlayerObject = _add_player("Crono")
var marle: PlayerObject = _add_player("Marle")
_next_song_is(SECOND_SONG)
await _play_first_song()
await _give_point_with_keybind(1)
_press("SongControls/ShowAnswerButton")
await _runner.simulate_frames(2)
return [crono, marle]
func _now_playing() -> Array[String]:
var now_playing: Node = _quiz.get_node("NowPlaying")
return [(now_playing.get_node("Titles/SoundtrackLabel") as Label).text,
(now_playing.get_node("Titles/SongLabel") as Label).text]
# Saving
func test_players_points_and_songs_are_saved_as_the_quiz_goes_on() -> void:
var players: Array[PlayerObject] = await _play_a_little()
var saved: QuizSave = await _saved()
assert_array(saved.players.map(func(p: PlayerObject) -> String: return p.player_name)) \
.contains_exactly(["Crono", "Marle"])
assert_int(saved.players[0].player_score).is_equal(1)
assert_int(saved.songs.size()).is_equal(2)
assert_array(saved.songs[0].players_given_point).contains_exactly([players[0].id])
assert_bool(saved.songs[0].is_answered).is_true()
assert_bool(saved.songs[0].is_playing).is_true()
assert_bool(saved.songs[1].has_played).is_false()
assert_int(saved.currently_playing_song).is_equal(0)
func test_taking_a_point_is_saved() -> void:
await _play_a_little()
await _take_point_with_keybind(1)
var saved: QuizSave = await _saved()
assert_int(saved.players[0].player_score).is_equal(0)
assert_array(saved.songs[0].players_given_point).is_empty()
func test_picking_a_character_is_saved() -> void:
var crono: PlayerObject = _add_player("Crono")
_player_row(crono).change_character()
var texture: ImageTexture = ImageTexture.create_from_image(
Image.create(4, 4, false, Image.FORMAT_RGB8))
_quiz.character_select_popup.character_selected.emit(texture, "Frog.png")
assert_str((await _saved()).players[0].character_name).is_equal("Frog.png")
func test_removing_a_player_is_saved() -> void:
var players: Array[PlayerObject] = await _play_a_little()
await _remove_player(players[0])
var saved: QuizSave = await _saved()
assert_int(saved.players.size()).is_equal(1)
assert_array(saved.songs[0].players_given_point).is_empty()
func test_reset_points_saves_a_new_game() -> void:
await _play_a_little()
_press("QuizToolbar/ResetPointsButton")
var saved: QuizSave = await _saved()
assert_int(saved.players.size()).is_equal(2)
assert_int(saved.players[0].player_score).is_equal(0)
assert_array(saved.songs[0].players_given_point).is_empty()
assert_int(saved.game_start_song).is_equal(1)
func test_a_sync_saves_a_new_game() -> void:
await _play_a_little()
_quiz.on_sync_finished()
await _runner.simulate_frames(3)
var saved: QuizSave = await _saved()
assert_int(saved.players[0].player_score).is_equal(0)
assert_str(saved.players[0].player_name).is_equal("Crono")
func test_winning_is_saved_with_statistics_off() -> void:
Settings.winning_score = 1
await _play_a_little()
assert_bool((await _saved()).stats_paused_by_win).is_true()
# Continuing after a restart
func test_quiz_goes_on_after_a_restart() -> void:
var before: Array[PlayerObject] = await _play_a_little()
await _restart_game([SONG_INFO[0], SECOND_SONG])
assert_int(Settings.player_array.size()).is_equal(2)
var crono: PlayerObject = Settings.player_array[0]
assert_str(crono.player_name).is_equal("Crono")
assert_str(crono.id).is_equal(before[0].id)
assert_int(crono.player_score).is_equal(1)
assert_bool(crono.is_first_point).is_false()
assert_int(Playlist.song_object_array.size()).is_equal(2)
assert_array(Playlist.song_object_array[0].players_given_point).contains_exactly([crono.id])
assert_bool(Playlist.song_object_array[0].is_answered).is_true()
assert_bool(Playlist.song_object_array[1].has_played).is_false()
assert_int(Playlist.currently_playing_song).is_equal(0)
assert_array(_now_playing()).contains_exactly(["Chrono Trigger", "Corridors of Time"])
# The server's playlist was kept, not reset
assert_int(_server.count("/music/reset")).is_equal(0)
func test_players_rows_show_the_saved_scores() -> void:
await _play_a_little()
await _restart_game([SONG_INFO[0], SECOND_SONG])
var row: PlayerRow = _player_row(Settings.player_array[0])
assert_str(row.player_name_field.text).is_equal("Crono")
assert_str(row.score_label.text).is_equal("1")
func test_song_downloaded_ahead_is_kept_and_not_downloaded_again() -> void:
await _play_a_little()
var file: String = (await _saved()).songs[1].song_path
await _restart_game([SONG_INFO[0], SECOND_SONG])
assert_bool(FileAccess.file_exists(file)).is_true()
assert_int(_server.count("/music/rand")).is_equal(0)
assert_bool((_quiz.get_node("SongControls/NextButton") as Button).disabled).is_false()
func test_next_plays_the_song_downloaded_before_the_restart() -> void:
await _play_a_little()
await _restart_game([SONG_INFO[0], SECOND_SONG])
_press("SongControls/NextButton")
await await_millis(700)
assert_int(Playlist.currently_playing_song).is_equal(1)
assert_bool(_quiz.music_player.audio_player.playing).is_true()
func test_song_that_was_playing_is_ready_to_play_again() -> void:
await _play_a_little()
await _restart_game([SONG_INFO[0], SECOND_SONG])
var player: MusicPlayer = _quiz.music_player
assert_object(player.audio_player.stream).is_not_null()
assert_bool(player.audio_player.playing).is_false()
assert_bool(player.shows_playing()).is_false()
player.play_or_pause()
assert_bool(player.audio_player.playing).is_true()
func test_keybinds_give_points_after_a_restart() -> void:
await _play_a_little()
await _restart_game([SONG_INFO[0], SECOND_SONG])
await _give_point_with_keybind(2)
assert_array(_scores()).contains_exactly([1, 1])
assert_int((await _saved()).players[1].player_score).is_equal(1)
func test_song_files_that_are_gone_are_downloaded_again() -> void:
await _play_a_little()
var saved: QuizSave = await _saved()
for song_object: SongObject in saved.songs:
DirAccess.remove_absolute(song_object.song_path)
_server.respond_bytes("/music?song=0", _mp3)
_server.respond_bytes("/music?song=1", _mp3)
await _restart_game([SONG_INFO[0], SECOND_SONG])
# The song that was playing and the one downloaded ahead
assert_int(_server.count("/music?song=0")).is_equal(1)
assert_int(_server.count("/music?song=1")).is_equal(1)
assert_object(Playlist.get_song(1)).is_not_null()
func test_unused_song_files_are_deleted_at_start() -> void:
await _play_a_little()
var stray: String = Playlist.songs_folder().path_join("left_over.mp3")
FileAccess.open(stray, FileAccess.WRITE).store_buffer(_mp3)
await _restart_game([SONG_INFO[0], SECOND_SONG])
assert_bool(FileAccess.file_exists(stray)).is_false()
assert_int(DirAccess.get_files_at(Playlist.songs_folder()).size()).is_equal(2)
func test_changed_server_playlist_starts_over_with_the_same_players() -> void:
await _play_a_little()
# The server's playlist was reset, e.g. by a sync from another computer
await _restart_game([{"SongNo": 0, "Soundtrack": "Terranigma", "Song": "Underworld"}])
assert_array(Settings.player_array.map(func(p: PlayerObject) -> String:
return p.player_name)).contains_exactly(["Crono", "Marle"])
assert_array(_scores()).contains_exactly([0, 0])
assert_str(Playlist.song_object_array[0].soundtrack_title).is_equal("Terranigma")
assert_bool(Playlist.song_object_array[0].has_played).is_true()
func test_songs_the_server_added_after_the_save_are_kept_as_played() -> void:
await _play_a_little()
var third: Dictionary = {"SongNo": 2, "Soundtrack": "Terranigma", "Song": "Underworld"}
await _restart_game([SONG_INFO[0], SECOND_SONG, third])
assert_int(Playlist.song_object_array.size()).is_equal(3)
assert_int(Settings.player_array[0].player_score).is_equal(1)
assert_bool(Playlist.song_object_array[2].has_played).is_true()
func test_another_server_starts_over_with_the_same_players() -> void:
await _play_a_little()
var server_before: String = Settings.default_path
Settings.default_path = "https://other.example.com"
await _restart_game([SONG_INFO[0], SECOND_SONG])
Settings.default_path = server_before
assert_int(Settings.player_array.size()).is_equal(2)
assert_array(_scores()).contains_exactly([0, 0])
func test_file_that_cannot_be_read_starts_a_new_quiz() -> void:
await _play_a_little()
await _runner.simulate_frames(1)
FileAccess.open(QuizSave.path(), FileAccess.WRITE).store_string("{ half a fi")
await _restart_game([SONG_INFO[0], SECOND_SONG])
assert_array(Settings.player_array).is_empty()
# Like any start without a saved quiz: the server's songs count as played, and a new
# one is downloaded ahead
assert_bool(Playlist.song_object_array[0].has_played).is_true()
assert_bool(Playlist.song_object_array[1].has_played).is_true()
assert_int(Playlist.song_object_array.size()).is_equal(3)
# The broken file is replaced by the new quiz
assert_object(await _saved()).is_not_null()
func test_statistics_stay_off_after_a_won_game() -> void:
Settings.winning_score = 1
await _play_a_little()
await _restart_game([SONG_INFO[0], SECOND_SONG])
assert_bool(Settings.add_to_stats).is_false()
_press("QuizToolbar/ResetPointsButton")
assert_bool(Settings.add_to_stats).is_true()
func test_nothing_is_saved_before_the_saved_songs_are_back() -> void:
await _play_a_little()
_server.hold("/music/list")
await _restart_game([SONG_INFO[0], SECOND_SONG])
var file_before: String = FileAccess.get_file_as_string(QuizSave.path())
# A player added while the server has not answered yet
_add_player("Lucca")
await _runner.simulate_frames(2)
assert_str(FileAccess.get_file_as_string(QuizSave.path())).is_equal(file_before)
_server.release("/music/list")
await _runner.simulate_frames(2)
assert_int((await _saved()).players.size()).is_equal(3)
assert_int((await _saved()).songs.size()).is_equal(2)
# Characters
func test_character_picture_comes_back_after_a_restart() -> void:
var picture: Image = Image.create(20, 10, false, Image.FORMAT_RGB8)
_server.respond_bytes("/character?name=Frog.png", picture.save_png_to_buffer())
var crono: PlayerObject = _add_player("Crono")
_player_row(crono).new_character_selected(ImageTexture.create_from_image(picture),
"Frog.png")
_quiz.save_soon()
await _restart_game()
crono = Settings.player_array[0]
assert_str(crono.character_name).is_equal("Frog.png")
assert_vector(crono.character.get_size()).is_equal(Vector2(20, 10))
assert_object(_player_row(crono).character.texture_normal).is_same(crono.character)
func test_characters_from_a_folder_picked_for_the_session_are_reset_after_a_restart() -> void:
var folder: String = create_temp_dir("session_characters_%d" % randi())
FileAccess.open(folder.path_join("Santa.png"), FileAccess.WRITE).store_buffer(
Image.create(8, 4, false, Image.FORMAT_RGB8).save_png_to_buffer())
_window.settings_popup.use_character_folder(folder)
var crono: PlayerObject = _add_player("Crono")
_player_row(crono).new_character_selected(ImageTexture.create_from_image(
Image.create(8, 4, false, Image.FORMAT_RGB8)), "Santa.png")
_quiz.save_soon()
# The folder is only used until the game is closed
await _restart_game()
assert_str(Settings.player_array[0].character_name).is_empty()
assert_str(Settings.player_array[0].character.resource_path) \
.is_equal(PlayerObject.NO_CHARACTER_PATH)
# A music folder
func _prepared_quiz_folder() -> String:
var root: String = create_temp_dir("continue_offline_%d" % randi())
for file_name: String in ["01. Opening.mp3", "02. Jingle Bells.mp3", "03. Finale.mp3"]:
FileAccess.open(root.path_join(file_name), FileAccess.WRITE).store_buffer(_mp3)
return root
func test_quiz_from_a_music_folder_goes_on_offline() -> void:
var folder: String = _prepared_quiz_folder()
_window.settings_popup.use_music_folder(folder)
await _runner.simulate_frames(2)
_add_player("Crono")
_press("SongControls/NextButton")
await await_millis(700)
await _give_point_with_keybind(1)
await _restart_game()
assert_bool(Settings.is_offline()).is_true()
assert_str(Settings.music_folder.path).is_equal(folder)
assert_bool(_window.top_bar.offline_label.visible).is_true()
assert_int(Settings.player_array[0].player_score).is_equal(1)
assert_str(Playlist.song_object_array[0].song_title).is_equal("01. Opening")
assert_array(_server.requested_paths.filter(func(path: String) -> bool:
return path.begins_with("/music"))).is_empty()
_press("SongControls/NextButton")
await await_millis(700)
assert_str(Playlist.song_object_array[Playlist.currently_playing_song].song_title) \
.is_equal("02. Jingle Bells")
func test_music_folder_that_is_gone_starts_over_with_the_server() -> void:
var folder: String = _prepared_quiz_folder()
_window.settings_popup.use_music_folder(folder)
await _runner.simulate_frames(2)
_add_player("Crono")
_press("SongControls/NextButton")
await await_millis(700)
await _give_point_with_keybind(1)
for file_name: String in DirAccess.get_files_at(folder):
DirAccess.remove_absolute(folder.path_join(file_name))
await _restart_game()
assert_bool(Settings.is_offline()).is_false()
assert_int(Settings.player_array.size()).is_equal(1)
assert_array(_scores()).contains_exactly([0])
assert_str(Playlist.song_object_array[0].soundtrack_title).is_equal("Chrono Trigger")
@@ -0,0 +1 @@
uid://dgxvnsquk18ud
+392
View File
@@ -0,0 +1,392 @@
extends MainWindowTestSuite
## Soundtrack covers on the statistics cards, in the popup and next to the answer in the
## quiz, loaded for the cards on screen; changing a cover from the popup; and editing a
## soundtrack's alternative names, which search then finds it by.
const STATISTICS: Array[Dictionary] = [
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
"cover_modified": 100, "alternative_names": [], "songs": [
{"song_name": "Corridors of Time", "times_played": 1},
]},
{"soundtrack_uuid": "st-2", "soundtrack_name": "Terranigma", "soundtrack_played": 0,
"songs": []},
]
var _view: StatisticsView
var _popup: SoundtrackStatsPopup
var _png: PackedByteArray
func _before_window_starts() -> void:
_server.respond_json("/statistics/soundtracks", STATISTICS)
var image: Image = Image.create(6, 8, false, Image.FORMAT_RGB8)
_png = image.save_png_to_buffer()
_server.respond_bytes("/soundtracks/st-1/cover", _png)
# The soundtrack list tells the quiz which soundtracks have covers
_server.respond_json("/soundtracks", [
{"uuid": "st-1", "name": "Chrono Trigger", "cover_modified": 100},
{"uuid": "st-2", "name": "Terranigma"},
])
_next_song_is({"SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "st-1",
"Song": "Corridors of Time"})
func before_test() -> void:
# Covers are kept next to the settings, which the tests keep in a folder of their own
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("covers_%d" % randi()))
await super()
_view = _window.get_node("StatisticsView")
_popup = _window.get_node("SoundtrackStatsPopup")
func _open_statistics() -> void:
_window.show_tab(&"statistics")
await _runner.simulate_frames(3)
func _card(soundtrack_name: String) -> SoundtrackCard:
for card: SoundtrackCard in _view.cards:
if card.soundtrack.soundtrack_name == soundtrack_name:
return card
return null
func test_card_shows_its_cover() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("Chrono Trigger")
assert_object(card.cover_picture.texture).is_not_null()
assert_bool(card.cover_placeholder.visible).is_false()
func test_card_without_a_cover_shows_initials_and_asks_nothing() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("Terranigma")
assert_object(card.cover_picture.texture).is_null()
assert_bool(card.cover_placeholder.visible).is_true()
assert_str(card.cover_placeholder.text).is_equal("T")
assert_int(_server.count("/soundtracks/st-2/cover")).is_equal(0)
func test_cover_missing_on_the_server_keeps_the_initials() -> void:
_server.respond_bytes("/soundtracks/st-1/cover", PackedByteArray(), 404)
await _open_statistics()
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
assert_str(_card("Chrono Trigger").cover_placeholder.text).is_equal("CT")
func test_covers_load_for_cards_on_screen_as_they_scroll_in() -> void:
var many: Array[Dictionary] = []
for i: int in 40:
many.append({"soundtrack_uuid": "many-%02d" % i, "soundtrack_name": "Game %02d" % i,
"soundtrack_played": 1, "cover_modified": 100, "songs": []})
_server.respond_bytes("/soundtracks/many-%02d/cover" % i, _png)
_server.respond_json("/statistics/soundtracks", many)
await _open_statistics()
var asked: Callable = func() -> int:
return _server.requested_paths.filter(
func(path: String) -> bool: return path.begins_with("/soundtracks/many-")).size()
var on_screen: int = asked.call()
assert_int(on_screen).is_greater(0)
assert_int(on_screen).is_less(40)
var scroll_bar: VScrollBar = _view.card_scroll.get_v_scroll_bar()
scroll_bar.value = scroll_bar.max_value
await _runner.simulate_frames(2)
assert_int(asked.call()).is_greater(on_screen)
func test_popup_shows_the_cover_large() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
assert_object(_popup.cover_picture.texture).is_not_null()
assert_bool(_popup.cover_placeholder.visible).is_false()
assert_str(_popup.names_label.text).is_equal("No other names yet")
func test_editing_names_saves_them_and_search_finds_the_new_name() -> void:
_server.respond_json("/soundtracks/st-1/alternative-names", {"uuid": "st-1",
"name": "Chrono Trigger", "alternative_names": ["CT", "クロノ・トリガー"]})
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
assert_bool(_popup.names_editor.visible).is_true()
_popup.new_name_field.text = "CT"
_popup.add_name()
_popup.new_name_field.text = "クロノ・トリガー"
_popup.save_names()
var index: int = _server.requested_paths.find("/soundtracks/st-1/alternative-names")
assert_int(index).is_not_equal(-1)
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
assert_array(body.names).contains_exactly(["CT", "クロノ・トリガー"])
assert_bool(_popup.names_editor.visible).is_false()
assert_str(_popup.names_label.text).is_equal("CT, クロノ・トリガー")
_view.search_field.text = "クロノ"
_view.apply_filter()
var shown: Array = _view.cards.filter(func(card: SoundtrackCard) -> bool: return card.visible)
assert_array(shown.map(func(card: SoundtrackCard) -> String:
return card.soundtrack.soundtrack_name)).contains_exactly(["Chrono Trigger"])
func test_editor_skips_empty_repeated_and_own_names() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
for name: String in [" ", "CT", "ct", "Chrono Trigger"]:
_popup.new_name_field.text = name
_popup.add_name()
assert_int(_popup.editor_names.get_child_count()).is_equal(1)
func test_removing_a_name_in_the_editor() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
_popup.new_name_field.text = "CT"
_popup.add_name()
_popup.remove_name("CT")
assert_int(_popup.editor_names.get_child_count()).is_equal(0)
func test_failed_save_keeps_the_editor_open() -> void:
_server.respond_json("/soundtracks/st-1/alternative-names", {}, 500)
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
_popup.new_name_field.text = "CT"
_popup.save_names()
assert_bool(_popup.names_editor.visible).is_true()
assert_bool(_popup.editor_status.visible).is_true()
assert_bool(_popup.save_names_button.disabled).is_false()
func test_search_popup_finds_soundtracks_by_alternative_names() -> void:
_server.respond_json("/soundtracks", [
{"uuid": "st-1", "name": "Chrono Trigger", "alternative_names": ["CT"]},
{"uuid": "st-2", "name": "Terranigma", "alternative_names": []},
])
var search: SearchPopup = _window.get_node("SearchPopup")
search.get_list_of_soundtracks()
search.open()
search.search_field.text = "ct"
search.search()
assert_array(search.shown_names()).contains(["Chrono Trigger"])
var row: PanelContainer = search.search_results.get_child(0)
assert_str((row.get_child(0) as RichTextLabel).get_parsed_text()).contains("CT")
func _open_cover_menu() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.change_cover()
# The server's answer for the soundtrack once its cover changed
func _cover_time_becomes(modified: int) -> void:
_server.respond_json("/soundtracks/st-1", {"uuid": "st-1", "name": "Chrono Trigger",
"cover_modified": modified, "songs": []})
func _picture_file(bytes: PackedByteArray) -> String:
var path: String = create_temp_dir("pictures_%d" % randi()).path_join("cover.jpg")
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
file.store_buffer(bytes)
file.close()
return path
func _downloads(path: String) -> int:
var count: int = 0
for i: int in _server.requested_paths.size():
if _server.requested_paths[i] == path \
&& _server.requested_methods[i] == HTTPClient.METHOD_GET:
count += 1
return count
func _now_playing() -> NowPlaying:
return _quiz.get_node("NowPlaying")
func test_cover_menu_opens_in_place_of_the_songs() -> void:
await _open_cover_menu()
assert_bool(_popup.cover_editor.visible).is_true()
assert_bool(_popup.song_scroll.visible).is_false()
assert_bool(_popup.edit_names_button.disabled).is_true()
assert_bool(_popup.remove_cover_button.disabled).is_false()
_popup.stop_editing()
assert_bool(_popup.cover_editor.visible).is_false()
assert_bool(_popup.song_scroll.visible).is_true()
func test_soundtrack_without_a_cover_has_nothing_to_remove() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Terranigma").soundtrack)
_popup.change_cover()
assert_bool(_popup.remove_cover_button.disabled).is_true()
func test_uploaded_picture_becomes_the_cover() -> void:
var bytes: PackedByteArray = FileAccess.get_file_as_bytes(
"res://tests/data/covers/cover_0.jpg")
_cover_time_becomes(200)
await _open_cover_menu()
_popup.upload_picture(_picture_file(bytes))
var index: int = _server.requested_methods.find(HTTPClient.METHOD_PUT)
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
assert_array(_server.requested_raw_bodies[index]).is_equal(bytes)
assert_array(_server.requested_headers[index]).contains(["Content-Type: image/jpeg"])
# The new cover is downloaded again for the popup and the card
assert_int(_card("Chrono Trigger").soundtrack.cover_modified).is_equal(200)
await _runner.simulate_frames(2)
# Downloaded once more, for the popup and the card together
assert_int(_downloads("/soundtracks/st-1/cover")).is_equal(2)
assert_object(_card("Chrono Trigger").cover_picture.texture).is_not_null()
assert_str(_popup.cover_status.text).is_equal("The new cover is saved.")
func test_file_that_is_not_a_picture_is_not_uploaded() -> void:
await _open_cover_menu()
_popup.upload_picture(_picture_file("not a picture".to_utf8_buffer()))
assert_int(_server.requested_methods.count(HTTPClient.METHOD_PUT)).is_equal(0)
assert_str(_popup.cover_status.text).contains("JPEG, PNG and WebP")
func test_cover_picked_by_id() -> void:
_server.respond_bytes("/soundtracks/st-1/cover/source", PackedByteArray(), 204)
_cover_time_becomes(300)
await _open_cover_menu()
_popup.cover_source_button.select(1)
_popup.cover_id_field.text = " 21 "
_popup.use_cover_source()
var index: int = _server.requested_paths.find("/soundtracks/st-1/cover/source")
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
assert_str(body.source).is_equal("anilist")
assert_str(body.id).is_equal("21")
assert_int(_popup.soundtrack.cover_modified).is_equal(300)
func test_no_id_asks_nothing() -> void:
await _open_cover_menu()
_popup.use_cover_source()
assert_int(_server.count("/soundtracks/st-1/cover/source")).is_equal(0)
func test_removed_cover_shows_the_initials() -> void:
_cover_time_becomes(0)
await _open_cover_menu()
_popup.remove_cover()
var index: int = _server.requested_methods.find(HTTPClient.METHOD_DELETE)
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
assert_bool(_popup.cover_placeholder.visible).is_true()
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
assert_bool(_popup.remove_cover_button.disabled).is_true()
func test_refused_change_shows_the_servers_reason() -> void:
_server.respond_json("/soundtracks/st-1/cover/source",
{"error": "covers are not enabled on this server"}, 503)
await _open_cover_menu()
_popup.cover_id_field.text = "21"
_popup.use_cover_source()
assert_str(_popup.cover_status.text).is_equal(
"Could not change the cover: covers are not enabled on this server.")
assert_bool(_popup.use_source_button.disabled).is_false()
func test_answer_shows_the_soundtracks_cover() -> void:
await _play_first_song()
assert_bool(_now_playing().has_cover()).is_false()
_press("SongControls/ShowAnswerButton")
assert_bool(_now_playing().has_cover()).is_true()
await _runner.simulate_frames(1)
# The cover sits just left of the titles, and the two are centered together
var area: Rect2 = _now_playing().get_global_rect()
var cover: Rect2 = _now_playing().cover_frame.get_global_rect()
var titles: Rect2 = _now_playing().titles.get_global_rect()
assert_float(titles.position.x - cover.end.x).is_equal(32.0)
assert_float(absf((cover.position.x - area.position.x) - (area.end.x - titles.end.x))) \
.is_less_equal(1.0)
# Up and down, the cover is centered between the top bar and the song list, and the
# titles on the cover
assert_float(absf((cover.position.y - area.position.y) - (area.end.y - cover.end.y))) \
.is_less_equal(1.0)
var text_middle: float = (_now_playing().soundtrack_label.get_global_rect().position.y
+ _now_playing().song_label.get_global_rect().end.y) / 2.0
assert_float(absf(text_middle - cover.get_center().y)).is_less_equal(1.0)
func test_answer_without_a_cover_shows_only_the_titles() -> void:
_view.covers.set_cover_times({"st-1": 0})
await _play_first_song()
_press("SongControls/ShowAnswerButton")
assert_bool(_now_playing().has_cover()).is_false()
assert_int(_server.count("/soundtracks/st-1/cover")).is_equal(0)
func test_next_song_hides_the_cover_until_its_answer() -> void:
await _play_first_song()
_press("SongControls/ShowAnswerButton")
_press("SongControls/NextButton")
# The playing song is stopped first, which takes half a second
await await_millis(700)
assert_int(Playlist.currently_playing_song).is_equal(1)
assert_bool(_now_playing().has_cover()).is_false()
func test_changed_cover_shows_in_the_quiz_too() -> void:
await _play_first_song()
_press("SongControls/ShowAnswerButton")
_cover_time_becomes(0)
_popup.show_soundtrack(SoundtrackWithSongs.from_dict(STATISTICS[0]))
_popup.change_cover()
_popup.remove_cover()
assert_bool(_now_playing().has_cover()).is_false()
@@ -0,0 +1 @@
uid://d3oc3e1fsttoh
+1 -1
View File
@@ -6,7 +6,7 @@ var _qr: QRPopup
func before_test() -> void: func before_test() -> void:
super() await super()
_log = _window.get_node("LogPopup") _log = _window.get_node("LogPopup")
_qr = _window.get_node("QRPopup") _qr = _window.get_node("QRPopup")
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more