31 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
Sansan 4a934840f6 Restyle the music player bar
Build / test (push) Successful in 1m11s
Build / build (push) Successful in 1m20s
The bar is rounded and a little taller, with a round orange play button
and a restart button that lights up orange under the mouse. The icons sit
inside their buttons with room around them, and are one colour, cropped to
their shape. Progress fills in orange, the volume slider gets a speaker
icon, and the buttons say their shortcut keys in their tooltips.

Buttons grow a little under the mouse and shrink while held down, and the
play button pops when it switches between play and pause. ButtonBounce
holds these animations so other buttons can use them too.
2026-10-09 17:38:38 +02:00
Sansan 78e1facaf0 Lay out the song list's pictures and text to fit any number of players
The players' pictures sit on the right in even lines of at most four, each
ending at the row's right edge, so five pictures are 3 + 2 and seven 4 + 3.
The text takes the room they leave and wraps only when it does not fit, and
the row is as tall as the taller of the two, instead of a width fixed by
the number of pictures.

Rows are rounded and alternate like the other lists, are highlighted under
the mouse, and the playing song has an orange edge. Clicking a picture
clicks its song.
2026-10-09 17:25:38 +02:00
Sansan 84e137e16a Only list played songs in the statistics song list
Songs never played are left out of the song list, so the Show menu is
hidden while it is shown.
2026-10-09 17:10:21 +02:00
Sansan a4e6da4c28 Show every song in the statistics, as an alternative to the cards
A Soundtracks / Songs switch in the statistics toolbar shows a list of
every song with its soundtrack and how often it was played. It is searched
on both song and soundtrack names, filtered and sorted like the cards, and
clicking a song shows its soundtrack's popup.

The server has tens of thousands of songs, so the list only makes the rows
on screen and reuses them while scrolling, songs are sorted once per load,
and searching waits for a pause in typing.

Closes #47
2026-10-09 17:02:27 +02:00
Sansan cd4331881c Don't list unplayed songs on the statistics cards
Cards only list songs that have been played. With fewer than six played
songs they are all listed as most played, so no song is listed twice. The
soundtrack popup still lists every song.

Closes #46
2026-10-09 16:55:39 +02:00
Sansan 06f457b027 Add a button in Settings that opens the config folder
Shows the folder with config.ini, the saved settings and the cached
characters and songs in the system's file manager.

Closes #45
2026-10-09 16:54:45 +02:00
Sansan 222d231b1b Add offline mode: play from a music folder instead of the server
Build / build (push) Successful in 1m12s
Build / test (push) Successful in 1m2s
Settings has a Music folder row. A folder with subfolders is played as
soundtracks: Next picks a random subfolder and a random song in it, the
subfolder is the answer's soundtrack and the file name without its type the
song. The inspiration list and Search show the subfolders.

A folder with songs and no subfolders is a prepared quiz, played once in order
(natural sort) or at random, showing only the song title. It has no winning
score: after the last song Next becomes Show winner, and the players with the
most points win, ties included.

While a folder is used nothing but the characters is asked from the server:
Sync, QR, Statistics, the update check and play counting are off. Characters
fall back to the cached pictures when the server can't be reached. mp3, ogg and
wav play; other files and broken ones are skipped.

The old local playback code, the hidden Open button and the hidden Local toggle
are removed. Next now waits for the song to start before preparing the next one.
2026-10-07 23:07:47 +02:00
Sansan b120440404 Open the About popup the first time a new version starts
Build / test (push) Successful in 46s
Build / build (push) Successful in 1m12s
The last version the About popup was shown for is saved as last_seen_version in
settings.cfg. When the game starts with another version, About opens to show
what is new.
2026-10-07 22:12:39 +02:00
Sansan 41974ea5e3 Show in the top bar when a newer version is available
Build / test (push) Successful in 46s
Build / build (push) Successful in 1m12s
At start and after switching server, the game asks the server for the newest
MusicPlayer release and shows "Version X is available" in the top bar when it
is newer than this one.
2026-10-07 22:08:53 +02:00
Sansan 240c331ab2 Cache character pictures and songs on disk
Build / test (push) Successful in 53s
Build / build (push) Successful in 1m13s
Character pictures are kept in characters/ next to the settings, with an
index.json of when the server last changed each one and when it was
downloaded. Only new or changed pictures are downloaded, using the server's new
/character/details. Older servers fall back to the name list, and pictures are
downloaded again after 7 days.

Downloaded songs are written to songs/ and only their paths are kept in memory.
The folder is emptied when the game starts and when the playlist resets.
2026-10-07 21:57:27 +02:00
Sansan 62fa63fd8b Update only the queue ID in settings.cfg
Build / test (push) Successful in 44s
Build / build (push) Successful in 1m12s
Saving a new queue ID wrote every setting from memory, which could also save
a setting that is in use but not saved yet, like the debug winning score.
Now it loads the file, changes only the queue ID and saves it again.
2026-10-07 21:28:02 +02:00
Sansan 4a45e8516d Send the play queue ID to the server
Build / test (push) Successful in 45s
Build / build (push) Successful in 1m12s
The server keeps a play queue per client, identified by the X-Queue-Id
header. The client sends the ID it has for the selected server with every
request and keeps the one in each response, which is new after a reset or a
sync. The IDs are saved per server in the [queues] section of settings.cfg,
so the game goes on with the same queue after a restart.

HttpTransport now passes the response headers on to the client.
2026-10-07 21:23:37 +02:00
Sansan d21b3e2ce0 Save fullscreen and read server, password and debug from config.ini
Build / build (push) Successful in 1m12s
Build / test (push) Successful in 45s
The settings the game saves itself, the quiz settings and now fullscreen, move
to settings.cfg (settings_debug.cfg in debug mode).

config.ini in the same folder is edited by hand: the server to start with, the
API password and debug mode. The game writes it with everything commented out
when it is missing and never changes it after that. IniFile reads it: plain
key = value lines, whole-line # or ; comments and optional quotes.
2026-10-07 20:12:59 +02:00
Sansan 92e3a66075 Save the quiz settings to the config folder
Build / test (push) Successful in 45s
Build / build (push) Successful in 1m20s
The quiz card's settings are saved to quiz_settings.cfg in OS.get_config_dir()
on every change and loaded at start. Debug builds use their own file, and
MUSICPLAYER_CONFIG_DIR moves the file elsewhere so tests never touch it.

Count plays in statistics and Prefer less played soundtracks are not saved and
move from the quiz card to the Settings popup.
2026-10-07 18:13:00 +02:00
Sansan b510aa47f2 Split settings into a quiz card and an application Settings popup
Build / test (push) Successful in 44s
Build / build (push) Successful in 1m13s
- A Quiz settings button under the player bar opens a small card above
  it with the quiz settings, including Auto repeat, which moves there
  from under the player bar. The card does not dim the quiz, so it can
  be changed during a song, and grows to fit its rows.
- A Settings button in the top bar opens the application settings:
  server, fullscreen and songs to download ahead. Quick sync is gone,
  as the server no longer has it, and the server list is in Settings.
- Labels use sentence case, and seconds to hide show as whole numbers.
- Debug builds switch to the local server and a low winning score in
  the Settings autoload, before any scene asks the server for anything.
  The DEBUG label moves to the top bar, so it shows on every tab.
- Search moves from the top bar back to the quiz toolbar, as the
  Statistics tab has its own search.
2026-10-07 16:05:13 +02:00
Sansan 8d21b0617d Fix and tidy the Character select, Shortcuts, Log and QR popups
Build / test (push) Successful in 41s
Build / build (push) Successful in 1m13s
Character select
- A card only picks its character when it is clicked. Before, every card
  watched every click, so clicking below the panel could pick a card
  scrolled out of view there.
- Six columns of fixed-size cards that scroll for any number of
  characters, with a gap between the cards and the scrollbar.
- Pictures are read as PNG, JPG or WebP by their first bytes, so JPGs no
  longer print an error. Names are split into words ("Paper Mario") and
  the card under the mouse is highlighted.

Shortcuts
- Alt+Z restarts the song; it was listed as "Reset".
- Grouped by what the keys do, in sentence case, and the panel fits the
  list. A test checks that every button shortcut is listed.

Log
- Scrolls to the newest row, alternates row colours and says "Nothing
  has happened yet" when empty.

QR
- A title, and the code is set up in one place.
2026-10-07 15:24:43 +02:00
Sansan b2d547b650 Centre every popup in the window
Build / test (push) Successful in 40s
Build / build (push) Successful in 1m13s
Most popups sat where they had been placed by hand, up to 328 px off.
They are now all centred, and test_popups.gd checks it for every popup.
2026-10-07 14:53:51 +02:00
Sansan 845d08c260 Rework the Search popup and highlight what matched
- The search field is a single-line field, so Enter no longer adds a
  line break that made every search come up empty.
- The field, button and list use real font sizes from the theme instead
  of being drawn at half size and scaled up, so the text is sharp, and
  everything fits inside the panel.
- Results have alternating row backgrounds like the other lists, a count
  ("3 of 1045 soundtracks") and a message when nothing matches.
- The letters that matched are highlighted.
- Searches shorter than Settings.LETTERS_FOR_LOOSE_SEARCH (3) only find
  names that contain the text, instead of names with the letters far
  apart, which matched most of the library. The Statistics tab uses the
  same search.
2026-10-07 14:53:51 +02:00
220 changed files with 60021 additions and 13851 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,
to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone
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
+30
View File
@@ -0,0 +1,30 @@
class_name AudioFile
extends RefCounted
## Reads songs from files: the songs downloaded from the server, and the songs in a music
## folder.
## The file types Godot can play from a file while the game runs.
const PLAYABLE_EXTENSIONS: Array[String] = ["mp3", "ogg", "wav"]
## Whether [param path] is a file type the game can play.
static func is_playable(path: String) -> bool:
return path.get_extension().to_lower() in PLAYABLE_EXTENSIONS
## Reads the song in [param path]. Returns null if the file is missing, is not a playable
## type, or is broken.
static func load(path: String) -> AudioStream:
if !FileAccess.file_exists(path):
return null
var stream: AudioStream = null
match path.get_extension().to_lower():
"mp3":
stream = AudioStreamMP3.load_from_file(path)
"ogg":
stream = AudioStreamOggVorbis.load_from_file(path)
"wav":
stream = AudioStreamWAV.load_from_file(path)
if stream == null || stream.get_length() <= 0.0:
return null
return stream
+1
View File
@@ -0,0 +1 @@
uid://drle78nqw2ve0
+97
View File
@@ -0,0 +1,97 @@
class_name ButtonBounce
extends RefCounted
## Small animations that make buttons feel alive: a button grows a little under the mouse,
## shrinks while it is held down, and can pop when its icon changes.
##
## The button is scaled around its middle, so its place in a container does not change.
## With [member Settings.reduce_animations] on, buttons keep their size.
## How big a button is under the mouse.
const HOVER_SCALE: Vector2 = Vector2(1.08, 1.08)
## How big a button is while it is held down.
const PRESSED_SCALE: Vector2 = Vector2(0.92, 0.92)
## How small a button starts when it pops.
const POP_SCALE: Vector2 = Vector2(0.8, 0.8)
## How long growing or shrinking takes, in seconds.
const DURATION: float = 0.08
## How long a pop takes, in seconds.
const POP_DURATION: float = 0.18
const _TWEEN_META: StringName = &"button_bounce_tween"
const _PIVOT_META: StringName = &"button_bounce_pivot"
const _ADDED_META: StringName = &"button_bounce_added"
## Gives every button in [param tree] the bounce, also buttons added later, e.g. rows
## of the player list. Check buttons are left out: they span a whole settings row.
static func add_to_every_button(tree: SceneTree) -> void:
if !tree.node_added.is_connected(_on_node_added):
tree.node_added.connect(_on_node_added)
var nodes: Array[Node] = [tree.root]
while !nodes.is_empty():
var node: Node = nodes.pop_back()
_on_node_added(node)
nodes.append_array(node.get_children())
static func _on_node_added(node: Node) -> void:
if node is BaseButton && !(node is CheckButton) && !(node is CheckBox):
add_to(node)
## Makes [param button] grow under the mouse and shrink while held down. Adding it to
## the same button twice does nothing.
static func add_to(button: BaseButton) -> void:
if button.has_meta(_ADDED_META):
return
button.set_meta(_ADDED_META, true)
_keep_pivot_centred(button)
button.mouse_entered.connect(func() -> void:
if !button.disabled:
_scale_to(button, HOVER_SCALE))
button.mouse_exited.connect(func() -> void: _scale_to(button, Vector2.ONE))
button.button_down.connect(func() -> void: _scale_to(button, PRESSED_SCALE))
button.button_up.connect(func() -> void: _scale_to(button, resting_scale(button)))
## The size [param button] goes back to when it is not held down: bigger under the mouse.
static func resting_scale(button: BaseButton) -> Vector2:
return HOVER_SCALE if button.is_hovered() && !button.disabled else Vector2.ONE
## Shrinks [param button] and lets it spring back, e.g. when its icon changes.
static func pop(button: BaseButton) -> void:
if !Anim.enabled():
return
_keep_pivot_centred(button)
button.scale = POP_SCALE
var tween: Tween = _new_tween(button)
tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tween.tween_property(button, ^"scale", resting_scale(button), POP_DURATION)
static func _scale_to(button: BaseButton, target: Vector2) -> void:
if !Anim.enabled():
button.scale = Vector2.ONE
return
var tween: Tween = _new_tween(button)
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(button, ^"scale", target, DURATION)
# A new tween for [param button], stopping the one before so they never fight
static func _new_tween(button: BaseButton) -> Tween:
if button.has_meta(_TWEEN_META):
var running: Tween = button.get_meta(_TWEEN_META)
if running.is_valid():
running.kill()
var tween: Tween = button.create_tween()
button.set_meta(_TWEEN_META, tween)
return tween
static func _keep_pivot_centred(button: BaseButton) -> void:
button.pivot_offset = button.size / 2
if !button.has_meta(_PIVOT_META):
button.set_meta(_PIVOT_META, true)
button.resized.connect(func() -> void: button.pivot_offset = button.size / 2)
+1
View File
@@ -0,0 +1 @@
uid://c4vekt3m8q1p7
+111
View File
@@ -0,0 +1,111 @@
class_name CharacterCache
extends RefCounted
## Character pictures kept on disk in the [code]characters[/code] folder next to the
## settings, so each one is downloaded only when it changed on the server.
##
## [code]index.json[/code] in the folder records, for each picture, when the server last
## changed it and when it was downloaded:
## [codeblock]
## {"Crono.png": {"modified": 1730000000, "downloaded": 1730100000}}
## [/codeblock]
## A cached picture is used while the server's change time matches the index. Servers that
## can't tell when pictures changed answer 404 for [code]/character/details[/code]; then a
## cached picture is used until it is [constant MAX_AGE_SECONDS] old. When the server
## can't be reached at all, the cached pictures are used as they are.
const FOLDER_NAME: String = "characters"
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
## whether it changed.
const MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60
## The change time of a picture when the server can't tell it.
const UNKNOWN_TIME: int = FileCache.UNKNOWN_TIME
# The pictures on disk and their index
var _files: FileCache = FileCache.new(FOLDER_NAME)
# When the server last changed each picture on the list, or UNKNOWN_TIME
var _modified: Dictionary[String, int] = {}
# True when the server could not be reached and only cached pictures are shown
var _cache_only: bool = false
## The folder the pictures are kept in.
static func folder() -> String:
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
## [code]Array[String][/code]. Cached pictures of characters no longer on the server are
## deleted.
func load_list(on_done: Callable) -> void:
_files.load_index()
_cache_only = false
var details_fetched: Callable = func details_fetched(details: Array) -> void:
_modified.clear()
for character: CharacterDetails in details:
_modified[character.name] = character.modified
_list_loaded(on_done)
MusicServerClient.fetch_character_details(details_fetched, _load_names_only.bind(on_done))
## 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.
func fetch_image(character_name: String, on_done: Callable) -> void:
if (_cache_only || _is_fresh(character_name)) && _files.has_file(character_name):
var cached: Image = CharacterCard.image_from_bytes(_files.read(character_name))
if cached != null:
on_done.call(cached)
return
GameLog.warning("The cached picture of ", character_name, " is broken")
var image_fetched: Callable = func image_fetched(bytes: PackedByteArray) -> void:
var picture: Image = CharacterCard.image_from_bytes(bytes)
if picture == null:
GameLog.warning("Could not load the picture of ", character_name)
return
_files.store(character_name, bytes, _modified.get(character_name, UNKNOWN_TIME))
on_done.call(picture)
MusicServerClient.fetch_character_image(character_name, image_fetched)
# Servers older than /character/details only list the names
func _load_names_only(on_done: Callable) -> void:
var list_fetched: Callable = func list_fetched(list: Variant) -> void:
_modified.clear()
if list is Array:
for character_name: Variant in list:
_modified[str(character_name)] = UNKNOWN_TIME
_list_loaded(on_done)
MusicServerClient.fetch_character_list(list_fetched, _load_cached_only.bind(on_done))
# The server can't be reached, so the pictures already cached are shown and kept
func _load_cached_only(on_done: Callable) -> void:
GameLog.warning("Could not get the characters from the server, showing the cached ones")
_cache_only = true
var names: Array[String] = []
for character_name: String in _files.keys():
if _files.has_file(character_name):
names.append(character_name)
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
on_done.call(names)
func _list_loaded(on_done: Callable) -> void:
_files.keep_only(_modified)
var names: Array[String] = []
names.assign(_modified.keys())
on_done.call(names)
func _is_fresh(character_name: String) -> bool:
if !_files.keys().has(character_name):
return false
var modified: int = _modified.get(character_name, UNKNOWN_TIME)
if modified != UNKNOWN_TIME:
return _files.modified(character_name) == modified
return _files.age(character_name) < MAX_AGE_SECONDS
+1
View File
@@ -0,0 +1 @@
uid://34xwe7vs2md7
+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
+73
View File
@@ -0,0 +1,73 @@
class_name IniFile
extends RefCounted
## A small INI file for settings people edit by hand: [code][section][/code] headers and
## [code]key = value[/code] lines.
##
## Lines starting with [code];[/code] or [code]#[/code] are comments. Comments take whole
## lines, so a value such as a password can hold any character. Quotes around a value are
## optional and removed. Section and key names are not case-sensitive.
const TRUE_WORDS: Array[String] = ["true", "yes", "on", "1"]
const FALSE_WORDS: Array[String] = ["false", "no", "off", "0"]
var _sections: Dictionary[String, Dictionary] = {}
## Reads INI [param text]. Lines that are neither a section, a setting nor a comment are
## skipped with a warning.
static func parse(text: String) -> IniFile:
var ini: IniFile = IniFile.new()
var section: String = ""
var line_number: int = 0
for raw_line: String in text.split("\n"):
line_number += 1
var line: String = raw_line.strip_edges()
if line.is_empty() or line.begins_with(";") or line.begins_with("#"):
continue
if line.begins_with("[") and line.ends_with("]"):
section = line.substr(1, line.length() - 2).strip_edges().to_lower()
continue
var equals: int = line.find("=")
if equals <= 0:
push_warning("Line %d is not a setting: %s" % [line_number, line])
continue
var key: String = line.substr(0, equals).strip_edges().to_lower()
var value: String = line.substr(equals + 1).strip_edges()
if value.length() >= 2 and value[0] in ["\"", "'"] and value[-1] == value[0]:
value = value.substr(1, value.length() - 2)
if !ini._sections.has(section):
ini._sections[section] = {}
ini._sections[section][key] = value
return ini
## Reads the INI file at [param path]. A missing file gives an empty [IniFile].
static func load_file(path: String) -> IniFile:
return parse(FileAccess.get_file_as_string(path))
func has_value(section: String, key: String) -> bool:
return _sections.get(section.to_lower(), {}).has(key.to_lower())
## The sections that have at least one setting.
func get_sections() -> Array[String]:
return _sections.keys()
func get_string(section: String, key: String, default: String = "") -> String:
return _sections.get(section.to_lower(), {}).get(key.to_lower(), default)
## Reads true/false, yes/no, on/off or 1/0. Anything else gives [param default] and a
## warning.
func get_bool(section: String, key: String, default: bool) -> bool:
if !has_value(section, key):
return default
var value: String = get_string(section, key).to_lower()
if value in TRUE_WORDS:
return true
if value in FALSE_WORDS:
return false
push_warning("%s in [%s] should be true or false, not %s" % [key, section, value])
return default
+1
View File
@@ -0,0 +1 @@
uid://b262vckl8hbn4
+90 -11
View File
@@ -1,26 +1,40 @@
class_name SoundtrackSearch
extends RefCounted
## Finds soundtrack names matching a search text. Matches ignore case, spaces and
## special characters, and also find the letters in order with others in between.
## special characters. Searches of at least [code]Settings.LETTERS_FOR_LOOSE_SEARCH[/code]
## 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.
##
## 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] = []
# 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.
var _regex_cache: Dictionary = {}
## Replaces the names to search in.
func set_names(names: Array) -> void:
## Replaces the names to search in. [param alternatives] has the other names of a
## soundtrack, by its real name.
func set_names(names: Array, alternatives: Dictionary = {}) -> void:
_names.clear()
_matched_alternatives.clear()
for soundtrack: String in names:
_names.append({
"original": soundtrack,
"lowercase": soundtrack.to_lower(),
"cleaned": clean_term(soundtrack),
})
_add_name(soundtrack, soundtrack)
for alternative: String in alternatives.get(soundtrack, []):
_add_name(soundtrack, alternative)
## 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
@@ -30,22 +44,78 @@ func find_matches(search_text: String) -> Array[String]:
var seen: Dictionary = {}
var search_text_lower: String = search_text.to_lower()
var clean_search_text: String = clean_term(search_text)
var regex: RegEx = _compile_regex_cached(clean_search_text)
var passes: Array[Callable] = [
func(entry: Dictionary) -> bool: return entry.lowercase.contains(search_text_lower),
func(entry: Dictionary) -> bool: return entry.cleaned.contains(clean_search_text),
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null,
]
if clean_search_text.length() >= Settings.LETTERS_FOR_LOOSE_SEARCH:
var regex: RegEx = _compile_regex_cached(clean_search_text)
passes.append(
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null)
var matches: Array[String] = []
_matched_alternatives.clear()
for matches_pass: Callable in passes:
for entry: Dictionary in _names:
if !seen.has(entry.original) && matches_pass.call(entry):
seen[entry.original] = true
matches.append(entry.original)
if entry.text != entry.original:
_matched_alternatives[entry.original] = entry.text
return matches
## The positions of the letters in [param soundtrack_name] that [param search_text]
## matched, for highlighting them: the text itself if the name contains it, otherwise the
## letters found in order the way [method find_matches] finds them. Empty if it does not
## match.
static func matched_letters(soundtrack_name: String, search_text: String) -> Array[int]:
var positions: Array[int] = []
if search_text.is_empty():
return positions
var start: int = soundtrack_name.to_lower().find(search_text.to_lower())
if start >= 0:
for i: int in search_text.length():
positions.append(start + i)
return positions
# Clean the name piece by piece, remembering which letter each cleaned letter came from
var cleaned: String = ""
var source: Array[int] = []
var i: int = 0
while i < soundtrack_name.length():
var piece: String
var length: int = 1
if soundtrack_name.substr(i, 2).to_lower() == "'n":
piece = "and"
length = 2
else:
piece = clean_term(soundtrack_name[i])
for letter: String in piece:
cleaned += letter
source.append(i)
i += length
var clean_search_text: String = clean_term(search_text)
var found: int = cleaned.find(clean_search_text)
var cleaned_positions: Array[int] = []
if found >= 0:
for j: int in clean_search_text.length():
cleaned_positions.append(found + j)
elif clean_search_text.length() >= Settings.LETTERS_FOR_LOOSE_SEARCH:
var at: int = 0
for letter: String in clean_search_text:
at = cleaned.find(letter, at)
if at < 0:
return positions
cleaned_positions.append(at)
at += 1
for cleaned_position: int in cleaned_positions:
if !positions.has(source[cleaned_position]):
positions.append(source[cleaned_position])
return positions
## Cleans a term by removing spaces and replacing special characters.
## Matches MusicServer's clean_term() function.
static func clean_term(term: String) -> String:
@@ -53,6 +123,15 @@ static func clean_term(term: String) -> String:
.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
func _compile_regex_cached(search_term: String) -> RegEx:
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
+183
View File
@@ -0,0 +1,183 @@
class_name MusicFolder
extends RefCounted
## A folder of songs to play instead of the MusicServer's, picked in the settings.
##
## A folder with subfolders is a soundtrack folder: each subfolder is a soundtrack, and
## songs are picked at random from a random soundtrack. Songs at the top are left out.
## A folder with songs and no subfolders is a prepared quiz: every song plays once, in
## 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
## 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 PlayOrder { IN_ORDER, RANDOM }
## How many files are tried before giving up when files can't be read.
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.
var path: String = ""
var kind: Kind = Kind.EMPTY
## How a prepared quiz is played. Change it with [method set_play_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
var _soundtracks: Dictionary[String, PackedStringArray] = {}
# Prepared quiz: the songs in order, the order they are played in and how many are played
var _quiz_songs: PackedStringArray = []
var _quiz_queue: PackedStringArray = []
var _quiz_next: int = 0
## Reads the folder at [param folder_path]. Its [member kind] is [constant Kind.EMPTY]
## when there is nothing to play in it.
static func scan(folder_path: String) -> MusicFolder:
var folder: MusicFolder = MusicFolder.new()
folder.path = folder_path.trim_suffix("/")
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():
for subfolder: String in subfolders:
var songs: PackedStringArray = _songs_in(folder.path.path_join(subfolder), true)
if !songs.is_empty():
folder._soundtracks[subfolder] = songs
if !folder._soundtracks.is_empty():
folder.kind = Kind.SOUNDTRACKS
else:
folder._quiz_songs = _songs_in(folder.path, false)
if !folder._quiz_songs.is_empty():
folder.kind = Kind.PREPARED_QUIZ
folder.restart()
return folder
## The folder's name, to show to the user.
func display_name() -> String:
return path.get_file()
## The names of the soundtracks, sorted. Empty for a prepared quiz.
func soundtrack_names() -> Array[String]:
var names: Array[String] = []
names.assign(_soundtracks.keys())
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
return names
## How many songs a prepared quiz has.
func quiz_size() -> int:
return _quiz_songs.size()
## Plays a prepared quiz in [param order] from its first song.
func set_play_order(order: PlayOrder) -> void:
play_order = order
restart()
## Starts a prepared quiz over from its first song, shuffled again if it is played at
## random.
func restart() -> void:
_quiz_queue = _quiz_songs.duplicate()
if play_order == PlayOrder.RANDOM:
var shuffled: Array = Array(_quiz_queue)
shuffled.shuffle()
_quiz_queue = PackedStringArray(shuffled)
_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
## can be read.
func next_song() -> SongObject:
for i: int in MAX_TRIES:
var song_path: String = _pick()
if song_path.is_empty():
return null
if AudioFile.load(song_path) != null:
return _song_object(song_path)
GameLog.warning("Skipping a song that can't be played: ", song_path)
return null
func _pick() -> String:
if kind == Kind.SOUNDTRACKS:
var songs: PackedStringArray = _soundtracks[_soundtracks.keys().pick_random()]
return songs[randi() % songs.size()]
if _quiz_next >= _quiz_queue.size():
return ""
_quiz_next += 1
return _quiz_queue[_quiz_next - 1]
func _song_object(song_path: String) -> SongObject:
var song_object: SongObject = SongObject.new()
song_object.song_path = song_path
song_object.song_title = song_path.get_file().get_basename()
if kind == Kind.SOUNDTRACKS:
# The soundtrack is the subfolder right under the music folder
song_object.soundtrack_title = song_path.trim_prefix(path + "/").get_slice("/", 0)
return song_object
# The playable songs in [param dir], in natural order, also in subfolders if [param deep]
static func _songs_in(dir: String, deep: bool) -> PackedStringArray:
var songs: Array[String] = []
for file_name: String in _visible(DirAccess.get_files_at(dir)):
if AudioFile.is_playable(file_name):
songs.append(dir.path_join(file_name))
songs.sort_custom(func(a: String, b: String) -> bool:
return a.get_file().naturalnocasecmp_to(b.get_file()) < 0)
if deep:
for subfolder: String in _visible(DirAccess.get_directories_at(dir)):
songs.append_array(_songs_in(dir.path_join(subfolder), true))
return PackedStringArray(songs)
static func _visible(names: PackedStringArray) -> PackedStringArray:
var shown: PackedStringArray = []
for entry: String in names:
if !entry.begins_with("."):
shown.append(entry)
return shown
+1
View File
@@ -0,0 +1 @@
uid://bcrem1inl47ve
+43 -3
View File
@@ -21,6 +21,9 @@ var id: String = _generate_uuid()
var is_first_point: bool = true
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:
@@ -28,6 +31,39 @@ func _init(new_name: String) -> void:
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".
static func _generate_uuid() -> String:
var bytes: PackedByteArray = Crypto.new().generate_random_bytes(16)
@@ -38,8 +74,12 @@ static func _generate_uuid() -> String:
hex.substr(16, 4), hex.substr(20, 12)]
## Gives the player a point. With a winning score, the point that reaches it wins; a
## prepared quiz has none, so points just add up until every song has played.
func add_point() -> void:
if player_score < Settings.winning_score:
var has_winning_score: bool = !Settings.is_prepared_quiz()
if has_winning_score && player_score >= Settings.winning_score:
return
player_score += 1
player_point_given.emit()
var first_point: bool = player_score == 1 && is_first_point
@@ -47,9 +87,9 @@ func add_point() -> void:
is_first_point = false
# With a low winning score the first point can also be match point or the win,
# so the more important event is checked first
if player_score == Settings.winning_score:
if has_winning_score && player_score == Settings.winning_score:
winner_triggered.emit()
elif player_score == Settings.winning_score - 1:
elif has_winning_score && player_score == Settings.winning_score - 1:
match_point_triggered.emit()
elif first_point:
first_point_triggered.emit()
+73 -4
View File
@@ -3,6 +3,13 @@ extends Node
## played, answered and playing.
##
## Registered as the [code]Playlist[/code] autoload.
##
## 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: when the game
## 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"
var song_object_array: Array[SongObject] = []
var latest_played_song: int = 0
@@ -15,6 +22,14 @@ var is_song_downloading: bool = false
## ended with Reset points.
var game_start_song: int = 0
# How many song files have been written, so each gets a name of its own
var _song_files_written: int = 0
## The folder downloaded songs are kept in.
static func songs_folder() -> String:
return Settings.config_dir().path_join(SONGS_FOLDER_NAME)
func get_next_song() -> SongObject:
return song_object_array[latest_played_song]
@@ -48,7 +63,7 @@ func song_is_answered(song_no: int = currently_playing_song) -> void:
return
GameLog.debug("show_answer_pressed song_object: ", song_object_array[song_no]._to_string())
song_object_array[song_no].is_answered = true
if Settings.add_to_stats:
if Settings.add_to_stats && !Settings.is_offline():
GameLog.debug("add to stats: ", song_no)
MusicServerClient.mark_song_played(song_no)
@@ -73,6 +88,7 @@ func reset_playlist() -> void:
latest_played_song = 0
game_start_song = 0
clear_song_list()
clear_song_files()
func clear_song_list() -> void:
@@ -92,6 +108,10 @@ func get_current_soundtrack_title() -> String:
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:
return song_object_array[currently_playing_song].get_song_title()
@@ -116,12 +136,61 @@ func remove_player_from_list(player_id: String) -> void:
song_object.remove_point(player_id)
## The song of [param song_no], read from its file. Returns null if the file is gone, so
## the song can be downloaded again.
func get_song(song_no: int) -> AudioStream:
return song_object_array[song_no].song
var song_object: SongObject = song_object_array[song_no]
if song_object.song_path.is_empty():
return song_object.song
return AudioFile.load(song_object.song_path)
func add_song_to_song_object(song_no: int, sound: AudioStream) -> void:
song_object_array[song_no].song = sound
func add_song_to_song_object(song_no: int, mp3: PackedByteArray) -> void:
store_song(song_object_array[song_no], mp3)
## Keeps a downloaded song in a file in [method songs_folder]. If the file can't be
## written, the song is kept in memory instead.
func store_song(song_object: SongObject, mp3: PackedByteArray) -> void:
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)
while FileAccess.file_exists(path):
_song_files_written += 1
path = songs_folder().path_join("song_%d.mp3" % _song_files_written)
_song_files_written += 1
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
if file == null:
GameLog.warning("Could not save a song to ", path, ", keeping it in memory: ",
error_string(FileAccess.get_open_error()))
var sound: AudioStreamMP3 = AudioStreamMP3.new()
sound.data = mp3
song_object.song = sound
song_object.song_path = ""
return
file.store_buffer(mp3)
file.close()
song_object.song = null
song_object.song_path = path
## Deletes the files of the songs downloaded so far, except those in [param keep].
## 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()):
return
for file_name: String in DirAccess.get_files_at(songs_folder()):
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:
+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
+261 -3
View File
@@ -3,38 +3,296 @@ extends Node
##
## 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
## 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
## that contain the text, since a letter or two in order matches most of the library.
const LETTERS_FOR_LOOSE_SEARCH: int = 3
## The MusicServers that can always be picked in the settings.
const SERVERS: Array[String] = [
"https://music.sanplex.xyz", "https://tmusic.sanplex.xyz", "https://rmusic.sanplex.xyz",
"https://old-music.sanplex.xyz", "http://localhost:8080",
]
## The server debug mode uses.
const DEBUG_SERVER: String = "http://localhost:8080"
## The winning score debug mode starts with, so a game is quick to win while testing.
const DEBUG_WINNING_SCORE: int = 5
## The settings the game saves by itself, by section, in [method saved_settings_path].
## Count plays in statistics and Prefer less played soundtracks are left out on purpose.
const SAVED_SETTINGS: Dictionary[String, Array] = {
"quiz": [
&"auto_repeat_song", &"stop_after_current", &"hide_next_track", &"hide_beginning",
&"hide_for_seconds", &"hide_length", &"hide_ticks", &"winning_score",
&"inspiration_list_speed", &"short_songs", &"short_song_seconds",
],
"window": [&"fullscreen"],
"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
## 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"
## The file the user edits by hand. The game writes it only when it is missing, with every
## setting commented out.
const CONFIG_FILE_NAME: String = "config.ini"
const CONFIG_TEMPLATE: String = """# MusicPlayer config. The game never changes this file, so edit it as you like.
# Lines starting with # or ; are comments. Remove the # in front of a setting to use it,
# and put it back to go back to the default.
[server]
# The MusicServer to use when the game starts. Another one can still be picked in
# Settings for as long as the game runs. Debug mode uses http://localhost:8080.
#url = https://music.sanplex.xyz
# Password for getting an API token from the server.
#password =
[game]
# true or false. Debug mode uses the local server, starts with a winning score of 5 and
# shows DEBUG in the top bar. Without this setting, the game is in debug mode when run
# from the Godot editor and not when exported.
#debug = true
"""
var default_path: String = "https://music.sanplex.xyz"
## Password for getting an API token from the server. Any password works when the
## server has none set.
var api_password: String = ""
## The servers that can be picked in Settings: [constant SERVERS], plus the server from
## the config file when it is not one of them.
var servers: Array[String] = SERVERS.duplicate()
var selected_server: int = 0
var is_local: bool = false
var is_debug: bool = false
var stop_after_current: bool = true
var auto_repeat_song: bool = true
var hide_next_track: 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 fullscreen: bool = false
var play_local: bool = false
var inspiration_list_speed: int = 1
var hide_for_seconds: float = 5.0
var hide_beginning: bool = false
var hide_length: 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 edit_players: bool = false
var currently_syncing: bool = false
var character_select_open: bool = false
## The ID of the game's play queue on each server, by server URL, set by
## [code]MusicServerClient[/code]. Saved in the [code][queues][/code] section of
## [method saved_settings_path], so the game goes on with the same queue after a restart.
var queue_ids: Dictionary[String, String] = {}
## The version the game last showed the About popup for. When it is not [member version],
## this version is new and the main window opens the About popup to show what is new.
var last_seen_version: String = ""
## Turns every animation off, so nothing moves. See [Anim].
var reduce_animations: bool = false
## The id of the theme in use, one of [constant THEMES].
var theme_id: String = THEMES[0].id
## The folder songs are played from instead of the server, picked in the settings, or null
## to use the server. Kept for this session only.
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.
## Releases are made from it, see RELEASING.md.
var version: String = ProjectSettings.get_setting("application/config/version", "")
# Autoloads are ready before any scene, so the server and debug mode from the config file
# are used from the very first request. The saved settings come after, so the debug
# winning score is only used until another one is saved.
func _ready() -> void:
apply_config(load_config())
load_saved_settings()
_use_theme(theme_id)
if fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
## Uses the server, password and debug mode from the hand-edited [param config]. Settings
## left out of it get their defaults.
func apply_config(config: IniFile) -> void:
is_debug = config.get_bool("game", "debug", OS.has_feature("debug"))
default_path = DEBUG_SERVER if is_debug else SERVERS[0]
if is_debug:
winning_score = DEBUG_WINNING_SCORE
var url: String = config.get_string("server", "url")
if !url.is_empty():
default_path = url.trim_suffix("/")
if !servers.has(default_path):
servers.append(default_path)
selected_server = servers.find(default_path)
api_password = config.get_string("server", "password")
## The folder with the settings files: a MusicPlayer folder in the user's config folder,
## e.g. [code]~/.config[/code] on Linux and [code]%APPDATA%[/code] on Windows.
func config_dir() -> String:
var dir: String = OS.get_environment(CONFIG_DIR_ENV)
if dir.is_empty():
dir = OS.get_config_dir().path_join("MusicPlayer")
return dir
## Where the hand-edited config file is.
func config_path() -> String:
return config_dir().path_join(CONFIG_FILE_NAME)
## Where the game saves its settings. Debug mode uses a file of its own, so testing never
## changes the real settings.
func saved_settings_path() -> String:
var file_name: String = "settings_debug.cfg" if is_debug else "settings.cfg"
return config_dir().path_join(file_name)
## Reads the hand-edited config file. When it is missing, writes one with every setting
## commented out, so there is a file to edit.
func load_config() -> IniFile:
var path: String = config_path()
if !FileAccess.file_exists(path):
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var template: FileAccess = FileAccess.open(path, FileAccess.WRITE)
if template != null:
template.store_string(CONFIG_TEMPLATE)
return IniFile.load_file(path)
## Loads the settings the game saved. Settings missing from the file, or saved with the
## wrong type, keep their current value.
func load_saved_settings() -> void:
var file: ConfigFile = ConfigFile.new()
if file.load(saved_settings_path()) != OK:
return
for section: String in SAVED_SETTINGS:
for setting: StringName in SAVED_SETTINGS[section]:
set(setting, _saved_value(file, section, setting, get(setting)))
if file.has_section("queues"):
queue_ids.clear()
for server: String in file.get_section_keys("queues"):
var id: Variant = file.get_value("queues", server)
if id is String:
queue_ids[server] = id
## Saves the settings in [constant SAVED_SETTINGS], so they are the same the next time
## the game starts.
func save_settings() -> void:
var path: String = saved_settings_path()
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file: ConfigFile = ConfigFile.new()
for section: String in SAVED_SETTINGS:
for setting: StringName in SAVED_SETTINGS[section]:
file.set_value(section, setting, get(setting))
for server: String in queue_ids:
file.set_value("queues", server, queue_ids[server])
var error: Error = file.save(path)
if error != OK:
GameLog.warning("Could not save the settings to %s: %s" % [path, error_string(error)])
## Sets the play queue ID for [param server] and saves it. Only the ID is changed in
## the file; the other settings in it stay as they were saved.
func set_queue_id(server: String, id: String) -> void:
queue_ids[server] = id
var path: String = saved_settings_path()
var file: ConfigFile = ConfigFile.new()
# A missing file is fine: it is written with only the queue ID
file.load(path)
file.set_value("queues", server, id)
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var error: Error = file.save(path)
if error != OK:
GameLog.warning("Could not save the queue ID to %s: %s" % [path, error_string(error)])
## Turns fullscreen on or off and saves it.
func set_fullscreen(on: bool) -> void:
fullscreen = on
if on:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
save_settings()
# The saved value of [param key], or [param default] when it is missing or of another type
func _saved_value(file: ConfigFile, section: String, key: String, default: Variant) -> Variant:
var value: Variant = file.get_value(section, key, default)
if typeof(value) != typeof(default):
push_warning("%s in [%s] should be a %s, not %s" % [key, section,
type_string(typeof(default)), var_to_str(value)])
return default
return value
## Whether songs come from a music folder instead of the server. Nothing but the
## characters is asked from the server then.
func is_offline() -> bool:
return music_folder != null
## Whether a prepared quiz is played from a music folder. It has no winning score: the
## game ends when every song has played.
func is_prepared_quiz() -> bool:
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:
for n: Node in node.get_children():
node.remove_child(n)
+55 -5
View File
@@ -5,6 +5,8 @@ class_name SongObject
var song_number: int
var song_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.
var players_given_point: Array[String] = []
@@ -12,7 +14,14 @@ var players_given_point: Array[String] = []
var is_answered: bool = false
var has_played: bool = false
var is_playing: bool = false
## The song's file: a download in [method Playlist.songs_folder], or a song in the music
## folder. Read the song with [method Playlist.get_song].
var song_path: String = ""
## The song itself, only when it could not be saved to a file.
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:
@@ -29,18 +38,28 @@ func get_song_title() -> String:
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:
if Settings.is_debug:
var format_string: String = "%d. %s - %s | played: %s | answered: %s"
return format_string % [(song_number + 1), soundtrack_title, song_title, has_played,
is_answered]
return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(),
has_played, is_answered]
elif is_answered:
var format_string: String = "%d. %s - %s"
return format_string % [(song_number + 1), soundtrack_title, song_title]
return "%d. %s" % [(song_number + 1), _full_title()]
else:
return "??? - ???"
# "Soundtrack - Song", or only the song in a prepared quiz, which has no soundtracks
func _full_title() -> String:
if soundtrack_title.is_empty():
return song_title
return "%s - %s" % [soundtrack_title, song_title]
func add_point(player_id: String) -> void:
GameLog.debug("add_point")
if !players_given_point.has(player_id):
@@ -52,6 +71,37 @@ func remove_point(player_id: String) -> void:
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:
var format_string: String = "song_number: %d | soundtrack_title: %s | song_title: %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]
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"
platform="Web"
runnable=false
@@ -353,7 +93,7 @@ encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.2.options]
[preset.1.options]
custom_template/debug=""
custom_template/release=""
@@ -380,7 +120,7 @@ dotnet/include_scripts_content=false
dotnet/include_debug_symbols=true
dotnet/embed_build_outputs=false
[preset.3]
[preset.2]
name="Linux"
platform="Linux"
@@ -400,7 +140,7 @@ encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.3.options]
[preset.2.options]
custom_template/debug=""
custom_template/release=""
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="416" height="416" viewBox="48 48 416 416">
<!-- The pause shape of pause_icon_light.svg in one colour -->
<path fill="#f9f9f9" d="M224,435.8V76.1c0-6.7-5.4-12.1-12.2-12.1h-71.6c-6.8,0-12.2,5.4-12.2,12.1v359.7c0,6.7,5.4,12.2,12.2,12.2h71.6 C218.6,448,224,442.6,224,435.8z"/>
<path fill="#f9f9f9" d="M371.8,64h-71.6c-6.7,0-12.2,5.4-12.2,12.1v359.7c0,6.7,5.4,12.2,12.2,12.2h71.6c6.7,0,12.2-5.4,12.2-12.2V76.1 C384,69.4,378.6,64,371.8,64z"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://djdsgbhw8gpj"
path="res://.godot/imported/pause_button_icon.svg-cf86876de39506a769c5610b329fdb75.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icons/pause_button_icon.svg"
dest_files=["res://.godot/imported/pause_button_icon.svg-cf86876de39506a769c5610b329fdb75.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="416" height="416" viewBox="64 48 416 416">
<!-- The play shape of play_icon_light.svg in one colour, shifted right so it looks centred -->
<path fill="#f9f9f9" transform="translate(16 0)" d="M405.2,232.9L126.8,67.2c-3.4-2-6.9-3.2-10.9-3.2c-10.9,0-19.8,9-19.8,20H96v344h0.1c0,11,8.9,20,19.8,20 c4.1,0,7.5-1.4,11.2-3.4l278.1-165.5c6.6-5.5,10.8-13.8,10.8-23.1C416,246.7,411.8,238.5,405.2,232.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dkweun1f426gl"
path="res://.godot/imported/play_button_icon.svg-76b19c7150b176f215ad401b4dd9488d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icons/play_button_icon.svg"
dest_files=["res://.godot/imported/play_button_icon.svg-76b19c7150b176f215ad401b4dd9488d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
<!-- reload_light_icon.svg mirrored, so the arrow turns back like a restart -->
<path fill="#f9f9f9" transform="translate(28 0) scale(-1 1)" d="M 12.273759,27.821814 C 8.301606,27.305128 4.6414458,25.025364 2.4172439,21.682595 -0.6740156,17.036719 -0.6987372,11.050866 2.3541959,6.4167276 6.4831292,0.1492961 14.7875,-1.7884148 21.095349,2.0437368 l 0.883303,0.5366251 0.09577,-0.6963111 c 0.07593,-0.5520493 0.198918,-0.7994615 0.593645,-1.1941885 0.682393,-0.6823936 1.29683,-0.7876518 2.135162,-0.3657721 1.11162,0.5594083 1.142977,0.6968977 1.099029,4.8188242 C 25.866598,8.48735 25.848168,8.714727 25.583585,9.075118 24.993194,9.879284 24.711316,9.931761 20.980939,9.931993 17.03905,9.932238 16.871859,9.891713 16.324094,8.803226 15.94065,8.041272 16.00249,7.4396205 16.528498,6.8144942 17.041282,6.2050862 17.302265,6.1106614 18.732442,6.0170974 l 1.29765,-0.084894 -0.711421,-0.4557502 C 16.407302,3.611373 12.42501,3.4754273 9.250891,5.1327637 6.9185825,6.3505563 5.186242,8.47902 4.3514391,11.152542 c -0.4596099,1.471937 -0.4596099,4.222979 0,5.694916 1.0532717,3.373185 3.4279179,5.747831 6.8011029,6.801103 0.874696,0.273122 1.291973,0.316826 2.983051,0.31243 1.791555,-0.0047 2.062404,-0.03855 3.050848,-0.381822 2.095349,-0.727675 4.004905,-2.225187 5.276499,-4.137935 0.712133,-1.071201 1.028577,-1.77101 1.500599,-3.318525 0.234713,-0.769505 0.466198,-1.242244 0.733848,-1.49867 0.768275,-0.736054 1.953395,-0.694987 2.67526,0.0927 0.56162,0.612834 0.678351,1.318164 0.398766,2.409492 -0.7664,2.991551 -3.210883,6.444937 -5.916304,8.35812 -2.688606,1.901292 -6.251776,2.77056 -9.58135,2.337459 z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://4t7vk634k0v4"
path="res://.godot/imported/restart_light_icon.svg-3822a30546bff23ba1adb74c1723ba80.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icons/restart_light_icon.svg"
dest_files=["res://.godot/imported/restart_light_icon.svg-3822a30546bff23ba1adb74c1723ba80.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
<g fill="#f9f9f9">
<rect x="11.75" y="2.5" width="4.5" height="5" rx="1"/>
<rect x="11.75" y="2.5" width="4.5" height="5" rx="1" transform="rotate(45 14 14)"/>
<rect x="11.75" y="2.5" width="4.5" height="5" rx="1" transform="rotate(90 14 14)"/>
<rect x="11.75" y="2.5" width="4.5" height="5" rx="1" transform="rotate(135 14 14)"/>
<rect x="11.75" y="2.5" width="4.5" height="5" rx="1" transform="rotate(180 14 14)"/>
<rect x="11.75" y="2.5" width="4.5" height="5" rx="1" transform="rotate(225 14 14)"/>
<rect x="11.75" y="2.5" width="4.5" height="5" rx="1" transform="rotate(270 14 14)"/>
<rect x="11.75" y="2.5" width="4.5" height="5" rx="1" transform="rotate(315 14 14)"/>
</g>
<circle cx="14" cy="14" r="6.5" fill="none" stroke="#f9f9f9" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 889 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bylmore1gmmnr"
path="res://.godot/imported/settings_light_icon.svg-2b0876b2265a32eb81d9266290ab6204.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icons/settings_light_icon.svg"
dest_files=["res://.godot/imported/settings_light_icon.svg-2b0876b2265a32eb81d9266290ab6204.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="#f9f9f9" d="M3 9.5v5c0 .55.45 1 1 1h3.2l4.1 3.6c.65.57 1.7.11 1.7-.76V5.66c0-.87-1.05-1.33-1.7-.76L7.2 8.5H4c-.55 0-1 .45-1 1z"/>
<path fill="none" stroke="#f9f9f9" stroke-width="2" stroke-linecap="round" d="M16 8.5a5 5 0 0 1 0 7M18.8 5.8a9 9 0 0 1 0 12.4"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d0uyxivsv58uh"
path="res://.godot/imported/volume_light_icon.svg-a1b08f489924a01123c9c4f7fa5e16a0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icons/volume_light_icon.svg"
dest_files=["res://.godot/imported/volume_light_icon.svg-a1b08f489924a01123c9c4f7fa5e16a0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+40 -20
View File
@@ -11,6 +11,8 @@
[ext_resource type="PackedScene" uid="uid://bijh5h5yrivm3" path="res://popups/LogPopup.tscn" id="14_26rwn"]
[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" 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]
layout_mode = 3
@@ -42,55 +44,64 @@ layout_mode = 1
[node name="SearchPopup" parent="." unique_id=1661250375 instance=ExtResource("9_5ijvr")]
visible = false
layout_mode = 1
offset_left = 56.0
offset_top = 96.0
offset_right = 56.0
offset_bottom = 96.0
offset_left = 384.0
offset_top = 100.0
offset_right = 384.0
offset_bottom = 100.0
[node name="SyncPopup" parent="." unique_id=182499699 instance=ExtResource("10_yxw0b")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 210.0
offset_top = 153.0
offset_top = 220.0
offset_right = 210.0
offset_bottom = 153.0
offset_bottom = 220.0
[node name="QRPopup" parent="." unique_id=589733821 instance=ExtResource("15_qr_window")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 720.0
offset_top = 153.0
offset_right = 720.0
offset_bottom = 153.0
offset_left = 735.0
offset_top = 270.0
offset_right = 735.0
offset_bottom = 270.0
[node name="ShortcutsPopup" parent="." unique_id=1513416242 instance=ExtResource("13_8eebo")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 562.0
offset_top = 160.0
offset_right = 1262.0
offset_bottom = 860.0
offset_left = 610.0
offset_top = 187.0
offset_right = 610.0
offset_bottom = 187.0
[node name="AboutPopup" parent="." unique_id=141261008 instance=ExtResource("16_about_window")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 360.0
offset_top = 153.0
offset_top = 220.0
offset_right = 360.0
offset_bottom = 153.0
offset_bottom = 220.0
[node name="LogPopup" parent="." unique_id=1289881388 instance=ExtResource("14_26rwn")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 562.0
offset_top = 132.0
offset_right = 1262.0
offset_bottom = 832.0
offset_left = 610.0
offset_top = 190.0
offset_right = 1310.0
offset_bottom = 890.0
[node name="SettingsPopup" parent="." unique_id=1323246978 instance=ExtResource("settings_popup_scene")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 700.0
offset_top = 405.0
offset_right = 700.0
offset_bottom = 405.0
[node name="SoundtrackStatsPopup" parent="." unique_id=1213999001 instance=ExtResource("soundtrack_stats_popup_scene")]
visible = false
@@ -99,3 +110,12 @@ offset_left = 610.0
offset_top = 140.0
offset_right = 610.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
+83 -47
View File
@@ -2,14 +2,6 @@
[ext_resource type="Script" path="res://main/top_bar.gd" id="top_bar_script"]
[sub_resource type="InputEventKey" id="InputEventKey_ujjlu"]
device = -1
alt_pressed = true
keycode = 83
[sub_resource type="Shortcut" id="Shortcut_fbju4"]
events = [SubResource("InputEventKey_ujjlu")]
[sub_resource type="ButtonGroup" id="ButtonGroup_tabs"]
[node name="TopBar" type="Control" unique_id=1489162753]
@@ -25,6 +17,7 @@ script = ExtResource("top_bar_script")
layout_mode = 0
offset_right = 1920.0
offset_bottom = 56.0
theme_type_variation = &"TopBarBackground"
[node name="Title" type="Label" parent="." unique_id=1187907861]
layout_mode = 0
@@ -36,63 +29,105 @@ size_flags_horizontal = 0
theme_type_variation = &"Title"
text = "Music Player Randomizer"
[node name="SyncButton" type="Button" parent="." unique_id=1228313556]
[node name="DebugLabel" type="Label" parent="." unique_id=1959221257]
visible = false
layout_mode = 0
offset_left = 1538.0
offset_top = 8.0
offset_right = 1608.0
offset_bottom = 48.0
offset_left = 384.0
offset_top = 12.0
offset_right = 470.0
offset_bottom = 50.0
theme_type_variation = &"HighlightLarge"
text = "DEBUG"
vertical_alignment = 1
[node name="UpdateBox" type="HBoxContainer" parent="." unique_id=1959221258]
visible = false
layout_mode = 0
offset_left = 1096.0
offset_top = 12.0
offset_right = 1432.0
offset_bottom = 50.0
mouse_filter = 2
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_overrun_behavior = 3
clip_text = true
alignment = 2
[node name="OfflineLabel" type="Label" parent="." unique_id=1959221259]
visible = false
layout_mode = 0
offset_left = 1096.0
offset_top = 12.0
offset_right = 1432.0
offset_bottom = 50.0
mouse_filter = 1
theme_type_variation = &"HighlightMedium"
text = "Offline: Music"
horizontal_alignment = 2
vertical_alignment = 1
text_overrun_behavior = 3
[node name="Buttons" type="HBoxContainer" parent="."]
layout_mode = 0
offset_left = 1200.0
offset_top = 10.0
offset_right = 1856.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
action_mode = 0
text = "Sync"
[node name="SearchButton" type="Button" parent="." unique_id=1782453581]
layout_mode = 0
offset_left = 1450.0
offset_top = 8.0
offset_right = 1520.0
offset_bottom = 48.0
[node name="SettingsButton" type="Button" parent="Buttons" unique_id=1228313557]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_fbju4")
text = "Search"
text = "Settings"
[node name="ShortcutsButton" type="Button" parent="." unique_id=2066498226]
layout_mode = 0
offset_left = 1626.0
offset_top = 8.0
offset_right = 1696.0
offset_bottom = 48.0
[node name="ShortcutsButton" type="Button" parent="Buttons" unique_id=2066498226]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Shortcuts"
[node name="AboutButton" type="Button" parent="." unique_id=1057035253]
layout_mode = 0
offset_left = 1722.0
offset_top = 8.0
offset_right = 1792.0
offset_bottom = 48.0
[node name="AboutButton" type="Button" parent="Buttons" unique_id=1057035253]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "About"
[node name="QRButton" type="Button" parent="." unique_id=2021908559]
layout_mode = 0
offset_left = 1802.0
offset_top = 8.0
offset_right = 1872.0
offset_bottom = 48.0
[node name="QRButton" type="Button" parent="Buttons" unique_id=2021908559]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "QR"
[node name="QuizTab" type="Button" parent="." unique_id=1903412671]
unique_name_in_owner = true
layout_mode = 0
offset_left = 840.0
offset_top = 8.0
offset_right = 955.0
offset_bottom = 48.0
offset_left = 839.0
offset_top = 10.0
offset_right = 954.0
offset_bottom = 46.0
focus_mode = 0
theme_type_variation = &"TabButton"
toggle_mode = true
@@ -102,11 +137,12 @@ button_group = SubResource("ButtonGroup_tabs")
text = "Quiz"
[node name="StatisticsTab" type="Button" parent="." unique_id=1903412672]
unique_name_in_owner = true
layout_mode = 0
offset_left = 965.0
offset_top = 8.0
offset_right = 1080.0
offset_bottom = 48.0
offset_left = 966.0
offset_top = 10.0
offset_right = 1081.0
offset_bottom = 46.0
focus_mode = 0
theme_type_variation = &"TabButton"
toggle_mode = true
+79 -7
View File
@@ -5,6 +5,9 @@ extends Control
##
## Everything that is not part of a quiz lives here.
## How far a tab slides in from the side when it is chosen.
const TAB_SLIDE: float = 60.0
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
var popup_backdrop: PopupBackdrop
@@ -16,39 +19,95 @@ var popup_backdrop: PopupBackdrop
@onready var qr_popup: QRPopup = %QRPopup
@onready var about_popup: AboutPopup = %AboutPopup
@onready var log_popup: LogPopup = %LogPopup
@onready var settings_popup: SettingsPopup = %SettingsPopup
@onready var update_popup: UpdatePopup = %UpdatePopup
@onready var statistics_view: StatisticsView = $StatisticsView
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
func _ready() -> void:
popup_backdrop = PopupBackdrop.add_to(self, _popups())
# Every button grows a little under the mouse and shrinks when clicked
ButtonBounce.add_to_every_button(get_tree())
if OS.has_feature("macos"):
GameLog.debug("is_mac")
top_bar.search_pressed.connect(show_search)
quiz.search_pressed.connect(show_search)
top_bar.sync_pressed.connect(sync_soundtracks)
top_bar.tab_selected.connect(show_tab)
top_bar.shortcuts_pressed.connect(show_shortcuts)
top_bar.about_pressed.connect(show_about)
top_bar.qr_pressed.connect(show_qr)
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.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)
sync_popup.sync_started.connect(_on_sync_started)
sync_popup.sync_finished.connect(_on_sync_finished)
quiz.server_changed.connect(search_popup.get_list_of_soundtracks)
quiz.server_changed.connect(_on_music_source_changed)
quiz.event_logged.connect(log_popup.add_log_row)
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()
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.
func _on_music_source_changed() -> void:
top_bar.update_offline_state()
top_bar.check_for_update()
if Settings.is_offline() && statistics_view.visible:
show_tab(&"quiz")
## Opens the About popup, with what is new, the first time this version starts.
func show_about_if_new_version() -> void:
if Settings.last_seen_version == Settings.version:
return
Settings.last_seen_version = Settings.version
Settings.save_settings()
show_about()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed:
if event.alt_pressed && event.keycode == KEY_ENTER:
GameLog.debug("Alt + Enter pressed")
Settings.fullscreen = !Settings.fullscreen
if Settings.fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
if !Settings.fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
Settings.set_fullscreen(!Settings.fullscreen)
if event.alt_pressed && event.keycode == KEY_K:
shortcuts_popup.toggle()
if event.alt_pressed && event.keycode == KEY_L:
@@ -57,7 +116,14 @@ func _input(event: InputEvent) -> void:
func _popups() -> Array[BasePopup]:
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
soundtrack_stats_popup]
soundtrack_stats_popup, settings_popup, update_popup]
# How many songs to download ahead can be changed in the settings. Waiting until the
# settings are closed avoids starting downloads while the number is clicked up and down.
func _on_settings_popup_visibility_changed() -> void:
if !settings_popup.visible:
quiz.song_loader.fill_cache()
func show_search() -> void:
@@ -85,8 +151,14 @@ func show_qr() -> void:
func show_tab(tab: StringName) -> void:
GameLog.debug("show_tab ", tab)
top_bar.select_tab(tab)
var was_statistics: bool = statistics_view.visible
statistics_view.visible = tab == &"statistics"
quiz.visible = !statistics_view.visible
# The tab that comes in slides from its side: statistics from the right
if statistics_view.visible && !was_statistics:
Anim.slide_in(statistics_view, Vector2(TAB_SLIDE, 0))
elif quiz.visible && was_statistics:
Anim.slide_in(quiz, Vector2(-TAB_SLIDE, 0))
if statistics_view.visible:
statistics_view.refresh()
+93 -8
View File
@@ -2,28 +2,113 @@ class_name TopBar
extends Control
## The bar along the top of the main window, with the title, the Quiz and Statistics tabs
## and the buttons for things that are not part of a quiz.
##
## With a music folder picked, the buttons that need the server are turned off and the
## folder's name is shown.
## Shown on the buttons that need the server while a music folder is used.
const OFFLINE_TOOLTIP: String = "Not available with a music folder"
signal search_pressed
signal sync_pressed
signal settings_pressed
signal shortcuts_pressed
signal about_pressed
signal qr_pressed
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
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
@onready var quiz_tab: Button = $QuizTab
@onready var statistics_tab: Button = $StatisticsTab
## 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 update_box: HBoxContainer = $UpdateBox
@onready var update_button: Button = %UpdateButton
@onready var offline_label: Label = $OfflineLabel
@onready var qr_button: Button = %QRButton
@onready var quiz_tab: Button = %QuizTab
@onready var statistics_tab: Button = %StatisticsTab
func _ready() -> void:
$SearchButton.pressed.connect(search_pressed.emit)
# Debug builds use the local server, see Settings
debug_label.visible = Settings.is_debug
sync_button.pressed.connect(sync_pressed.emit)
$ShortcutsButton.pressed.connect(shortcuts_pressed.emit)
$AboutButton.pressed.connect(about_pressed.emit)
$QRButton.pressed.connect(qr_pressed.emit)
%SettingsButton.pressed.connect(settings_pressed.emit)
%ShortcutsButton.pressed.connect(shortcuts_pressed.emit)
%AboutButton.pressed.connect(about_pressed.emit)
qr_button.pressed.connect(qr_pressed.emit)
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
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()
check_for_update()
## Turns the buttons that need the server off or on, after a music folder was picked or
## the server is used again.
func update_offline_state() -> void:
var offline: bool = Settings.is_offline()
for button: Button in [sync_button, qr_button, statistics_tab]:
button.disabled = offline
button.tooltip_text = OFFLINE_TOOLTIP if offline else ""
offline_label.visible = offline
if offline:
offline_label.text = "Offline: " + Settings.music_folder.display_name()
offline_label.tooltip_text = Settings.music_folder.path
update_box.visible = false
## 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.
func check_for_update() -> void:
if Settings.is_offline():
update_box.visible = false
return
MusicServerClient.fetch_latest_client_version(show_latest_version)
## Shows that [param latest] is available when it is newer than this version, and hides
## the message otherwise.
func show_latest_version(latest: Variant) -> void:
var newer: bool = latest is String && is_newer_version(latest, Settings.version) \
&& !Settings.is_offline()
update_box.visible = newer
if newer:
latest_version = latest
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
## [code]1.10.0[/code] part by part. A leading "v" is ignored, and missing parts count as 0.
static func is_newer_version(latest: String, current: String) -> bool:
var latest_parts: PackedStringArray = latest.strip_edges().trim_prefix("v").split(".")
var current_parts: PackedStringArray = current.strip_edges().trim_prefix("v").split(".")
for i: int in maxi(latest_parts.size(), current_parts.size()):
var a: int = latest_parts[i].to_int() if i < latest_parts.size() else 0
var b: int = current_parts[i].to_int() if i < current_parts.size() else 0
if a != b:
return a > b
return false
## Shows [param tab] as the chosen tab, without emitting [signal tab_selected].
+54 -32
View File
@@ -1,9 +1,10 @@
[gd_scene load_steps=10 format=3 uid="uid://ds15cgsf8vpvc"]
[gd_scene format=3 uid="uid://ds15cgsf8vpvc"]
[ext_resource type="Script" uid="uid://d1v0rquma6nt8" path="res://player/music_player.gd" id="1_t24ra"]
[ext_resource type="AudioStream" uid="uid://n2g8jddr85h2" path="res://sounds/01. Opening.mp3" id="2_xti80"]
[ext_resource type="Texture2D" uid="uid://comxqfiykp54f" path="res://icons/play_icon_light.svg" id="3_6g308"]
[ext_resource type="Texture2D" uid="uid://ccb6rvbldlgdg" path="res://icons/reload_light_icon.svg" id="4_jleuo"]
[ext_resource type="Texture2D" path="res://icons/play_button_icon.svg" id="3_6g308"]
[ext_resource type="Texture2D" path="res://icons/restart_light_icon.svg" id="4_jleuo"]
[ext_resource type="Texture2D" path="res://icons/volume_light_icon.svg" id="6_volume"]
[ext_resource type="PackedScene" uid="uid://w400rnew7453" path="res://player/VolumeSlider.tscn" id="5_iifuj"]
[sub_resource type="InputEventKey" id="InputEventKey_r4qo3"]
@@ -29,7 +30,7 @@ events = [SubResource("InputEventKey_2nvce")]
[node name="MusicPlayer" type="Control"]
layout_mode = 3
offset_right = 1792.0
offset_bottom = 71.0
offset_bottom = 42.0
mouse_filter = 2
script = ExtResource("1_t24ra")
@@ -46,62 +47,83 @@ stream = ExtResource("2_xti80")
bus = &"music"
[node name="PlayerBar" type="PanelContainer" parent="."]
layout_mode = 0
offset_left = 1.0
offset_right = 1792.0
offset_bottom = 32.0
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 = &"PlayerBar"
[node name="MusicPlayerContainer" type="HBoxContainer" parent="PlayerBar"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
theme_override_constants/separation = 10
[node name="PlayTextureButton" type="TextureButton" parent="PlayerBar/MusicPlayerContainer"]
[node name="PlayButton" type="Button" parent="PlayerBar/MusicPlayerContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(32, 32)
custom_minimum_size = Vector2(34, 34)
layout_mode = 2
size_flags_vertical = 4
tooltip_text = "Play or pause (Alt+X)"
focus_mode = 0
theme_type_variation = &"PlayerButtonPrimary"
action_mode = 0
shortcut = SubResource("Shortcut_ipcfh")
texture_normal = ExtResource("3_6g308")
ignore_texture_size = true
stretch_mode = 0
shortcut_feedback = false
shortcut_in_tooltip = false
icon = ExtResource("3_6g308")
icon_alignment = 1
expand_icon = true
[node name="RestartTextureButton" type="TextureButton" parent="PlayerBar/MusicPlayerContainer"]
[node name="RestartButton" type="Button" parent="PlayerBar/MusicPlayerContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(32, 32)
custom_minimum_size = Vector2(34, 34)
layout_mode = 2
size_flags_vertical = 4
tooltip_text = "Restart the song (Alt+Z)"
focus_mode = 0
theme_type_variation = &"PlayerButton"
action_mode = 0
shortcut = SubResource("Shortcut_i7swq")
texture_normal = ExtResource("4_jleuo")
ignore_texture_size = true
stretch_mode = 0
flip_h = true
shortcut_feedback = false
shortcut_in_tooltip = false
icon = ExtResource("4_jleuo")
icon_alignment = 1
expand_icon = true
[node name="MusicPlayerSlider" type="HSlider" parent="PlayerBar/MusicPlayerContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
theme_type_variation = &"ProgressSlider"
scrollable = false
[node name="MusicTimeLabel" type="Label" parent="PlayerBar/MusicPlayerContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 0)
layout_mode = 2
text = "0:00 / 0:00"
theme_type_variation = &"PlayerTime"
text = "00:00 / 00:00"
horizontal_alignment = 1
[node name="VolumeIcon" type="Button" parent="PlayerBar/MusicPlayerContainer"]
custom_minimum_size = Vector2(22, 22)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
focus_mode = 0
theme_type_variation = &"PlayerIcon"
icon = ExtResource("6_volume")
icon_alignment = 1
expand_icon = true
[node name="VolumeSlider" parent="PlayerBar/MusicPlayerContainer" instance=ExtResource("5_iifuj")]
unique_name_in_owner = true
custom_minimum_size = Vector2(100, 0)
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
size_flags_vertical = 4
[node name="RepeatSongCheckButton" type="CheckButton" parent="."]
layout_mode = 0
offset_top = 40.0
offset_right = 144.0
offset_bottom = 71.0
focus_mode = 0
button_pressed = true
action_mode = 0
text = "Auto repeat"
tooltip_text = "Volume"
theme_type_variation = &"VolumeBar"
+139 -73
View File
@@ -1,53 +1,118 @@
class_name MusicPlayer
extends Control
## The bar that plays the songs, with play, restart, progress and volume, and the
## auto repeat switch under it.
## The bar that plays the songs, with play, restart, progress and volume. Whether a song
## repeats is a quiz setting ([code]Settings.auto_repeat_song[/code]).
##
## The buttons grow a little under the mouse and the play button pops when it switches
## 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 play_next_song
var play_icon: Texture = preload("res://icons/play_icon_light.svg")
var pause_icon: Texture = preload("res://icons/pause_icon_light.svg")
var songs: Array = []
var play_icon: Texture = preload("res://icons/play_button_icon.svg")
var pause_icon: Texture = preload("res://icons/pause_button_icon.svg")
var is_changing: bool = false
var playback_position: float
var stream: AudioStream
var song_finished: bool = false
@onready var play_button: TextureButton = %PlayTextureButton
@onready var restart_button: TextureButton = %RestartTextureButton
## 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 restart_button: Button = %RestartButton
@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer
@onready var sound_player: AudioStreamPlayer = $AudioSoundPlayer
@onready var sound_effect_player: AudioStreamPlayer = $AudioSoundEffectPlayer
@onready var progress_slider: HSlider = %MusicPlayerSlider
@onready var volume_slider: VolumeSlider = %VolumeSlider
@onready var music_time_label: Label = %MusicTimeLabel
@onready var auto_repeat_button: CheckButton = $RepeatSongCheckButton
func _ready() -> void:
play_button.pressed.connect(play_or_pause)
restart_button.pressed.connect(restart)
ButtonBounce.add_to(play_button)
ButtonBounce.add_to(restart_button)
progress_slider.drag_started.connect(_on_drag_started)
progress_slider.drag_ended.connect(_on_drag_ended)
audio_player.finished.connect(_on_finished)
auto_repeat_button.pressed.connect(_on_auto_repeat_pressed)
func _on_auto_repeat_pressed() -> void:
Settings.auto_repeat_song = !Settings.auto_repeat_song
func _process(_delta: float) -> void:
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused:
if !Settings.hide_beginning \
|| audio_player.get_playback_position() > Settings.hide_for_seconds:
progress_slider.value = audio_player.get_playback_position()
if stream != null:
music_time_label.text = format_text(progress_slider.value, stream.get_length())
else:
music_time_label.text = format_text(progress_slider.value, 0.0)
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused \
&& audio_player.playing:
var position: float = position_in_part()
if _clip_length > 0.0 && position >= _clip_length:
# The short part has played, which counts as the song ending
audio_player.stop()
progress_slider.value = _clip_length
_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.
func shows_playing() -> bool:
return play_button.icon == pause_icon
# Shows pause on the play button while a song plays, and play otherwise
func _show_playing(playing: bool) -> void:
var icon: Texture = pause_icon if playing else play_icon
if play_button.icon != icon:
play_button.icon = icon
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:
@@ -71,16 +136,12 @@ func play_or_pause() -> void:
if song_finished:
restart()
elif audio_player.stream_paused:
play_button.texture_normal = pause_icon
_show_playing(true)
audio_player.stream_paused = false
song_finished = false
audio_player.seek(playback_position)
GameLog.debug("continue")
progress_slider.max_value = round(stream.get_length())
if Settings.hide_ticks:
progress_slider.tick_count = 0
else:
progress_slider.tick_count = round(stream.get_length() / 60)
_show_length()
else:
pause()
@@ -88,22 +149,22 @@ func play_or_pause() -> void:
func pause() -> void:
audio_player.stream_paused = true
playback_position = audio_player.get_playback_position()
play_button.texture_normal = play_icon
_show_playing(false)
## Plays the song, or its short part, from the beginning.
func restart() -> void:
audio_player.stop()
audio_player.stream_paused = false
song_finished = false
progress_slider.value = 0
playback_position = audio_player.get_playback_position()
audio_player.seek(playback_position)
play_button.texture_normal = pause_icon
audio_player.play()
playback_position = _clip_start
_show_playing(true)
audio_player.play(_clip_start)
func _on_finished() -> void:
play_button.texture_normal = play_icon
_show_playing(false)
song_finished = true
if !Settings.stop_after_current:
play_next_song.emit()
@@ -116,16 +177,18 @@ func _on_drag_started() -> void:
func _on_drag_ended(_changed: bool) -> void:
audio_player.seek(progress_slider.value)
playback_position = progress_slider.value
audio_player.seek(_clip_start + progress_slider.value)
playback_position = _clip_start + progress_slider.value
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:
progress_slider.value += new_position
is_changing = true
audio_player.seek(progress_slider.value)
playback_position = progress_slider.value
audio_player.seek(_clip_start + progress_slider.value)
playback_position = _clip_start + progress_slider.value
is_changing = false
@@ -157,44 +220,59 @@ func play_sound(sound_name: AudioStream) -> void:
audio_player.stop()
sound_player.play()
song_finished = true
play_button.texture_normal = play_icon
_show_playing(false)
progress_slider.value = 0
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.play()
sound_player.stop()
song_finished = false
play_button.texture_normal = pause_icon
_show_playing(true)
stream = audio_player.stream
progress_slider.value = 0
progress_slider.max_value = round(stream.get_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())
_show_length()
## Puts song [param song_object_no] in the player without playing it, e.g. the song that
## was playing when the game was closed. Play starts it from the beginning.
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:
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():
audio_player.stop()
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()
audio_player.play()
audio_player.play(_clip_start)
song_finished = false
play_button.texture_normal = pause_icon
_show_playing(true)
# A new song makes the play button pop, also when it was already playing
ButtonBounce.pop(play_button)
stream = audio_player.stream
progress_slider.value = 0
progress_slider.max_value = round(stream.get_length())
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())
_show_length()
Playlist.set_currently_playing_song(song_object_no)
Playlist.unset_is_playing()
if !Settings.hide_next_track:
@@ -205,31 +283,19 @@ func play_song_object(song_object_no: int) -> void:
update_song_list.emit()
## Plays a song to test the sound: one from the server, or a song that comes with the
## game when playing from a music folder.
func get_sound_test_song() -> void:
if Settings.is_offline():
sound_test_local()
return
var test_sound: Callable = func test_sound(body: PackedByteArray) -> void:
var sound: AudioStream = AudioStreamMP3.new()
sound.data = body
play_song(sound)
MusicServerClient.fetch_sound_test_song(test_sound)
##### LOCAL
var local_path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3'
func load_mp3(_path: String) -> AudioStream:
var file: FileAccess = FileAccess.open(_path, FileAccess.READ)
var sound: AudioStream = AudioStreamMP3.new()
sound.data = file.get_buffer(file.get_length())
return sound
func play_local_song() -> void:
if songs:
local_path = songs[0]
GameLog.debug(local_path)
GameLog.debug(FileAccess.file_exists(local_path))
play_sound(load_mp3(local_path))
# The sound test without a server: a song that comes with the game
func sound_test_local() -> void:
play_sound(preload("res://sounds/01. Opening.mp3"))
play_song(preload("res://sounds/01. Opening.mp3"))
+21 -22
View File
@@ -3,40 +3,39 @@
[ext_resource type="Script" uid="uid://i35b3scbpwpu" path="res://popups/character_card.gd" id="1_pjt0s"]
[ext_resource type="Texture2D" uid="uid://cv3hdd6bsruau" path="res://icon.svg" id="2_pjt0s"]
[node name="CharacterCard" type="Control"]
layout_mode = 3
anchors_preset = 0
[node name="CharacterCard" type="Panel"]
custom_minimum_size = Vector2(240, 150)
mouse_default_cursor_shape = 2
theme_type_variation = &"CharacterCard"
script = ExtResource("1_pjt0s")
[node name="Panel" type="Panel" parent="."]
layout_mode = 0
offset_right = 240.0
offset_bottom = 145.0
theme_type_variation = &"CharacterCard"
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 10.0
offset_top = 10.0
offset_right = -10.0
offset_bottom = -6.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="MarginContainer" type="MarginContainer" parent="Panel"]
layout_mode = 0
offset_right = 240.0
offset_bottom = 145.0
theme_override_constants/margin_left = 15
theme_override_constants/margin_top = 10
theme_override_constants/margin_right = 15
[node name="VBoxContainer" type="VBoxContainer" parent="Panel/MarginContainer"]
layout_mode = 2
[node name="CharacterTextureRect" type="TextureRect" parent="Panel/MarginContainer/VBoxContainer"]
[node name="CharacterTextureRect" type="TextureRect" parent="VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(200, 100)
layout_mode = 2
size_flags_horizontal = 4
size_flags_vertical = 4
mouse_filter = 2
texture = ExtResource("2_pjt0s")
expand_mode = 3
expand_mode = 1
stretch_mode = 5
[node name="CharacterName" type="Label" parent="Panel/MarginContainer/VBoxContainer"]
[node name="CharacterName" type="Label" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Aloy"
horizontal_alignment = 1
text_overrun_behavior = 3
+27 -30
View File
@@ -11,45 +11,42 @@ panel = NodePath("CharacterPanel")
[node name="CharacterPanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_right = 1290.0
offset_bottom = 665.0
offset_right = 1580.0
offset_bottom = 720.0
theme_type_variation = &"PopupBackground"
[node name="VBoxContainer" type="VBoxContainer" parent="CharacterPanel"]
layout_mode = 0
offset_right = 1290.0
offset_bottom = 659.0
size_flags_horizontal = 3
[node name="TitleLabel" type="Label" parent="CharacterPanel/VBoxContainer"]
[node name="TitleLabel" type="Label" parent="CharacterPanel"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 0
layout_mode = 0
offset_right = 1580.0
offset_bottom = 56.0
theme_type_variation = &"Title"
text = "Character Select"
horizontal_alignment = 1
vertical_alignment = 1
[node name="MarginContainer" type="MarginContainer" parent="CharacterPanel/VBoxContainer"]
layout_mode = 2
theme_override_constants/margin_left = 20
theme_override_constants/margin_top = 10
theme_override_constants/margin_right = 20
theme_override_constants/margin_bottom = 10
[node name="ScrollContainer" type="ScrollContainer" parent="CharacterPanel/VBoxContainer/MarginContainer"]
custom_minimum_size = Vector2(0, 600)
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="CharacterGridContainer" type="GridContainer" parent="CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer"]
[node name="ScrollContainer" type="ScrollContainer" parent="CharacterPanel"]
unique_name_in_owner = true
custom_minimum_size = Vector2(1150, 600)
layout_mode = 0
offset_left = 24.0
offset_top = 60.0
offset_right = 1564.0
offset_bottom = 704.0
horizontal_scroll_mode = 0
vertical_scroll_mode = 2
[node name="ScrollbarGap" type="MarginContainer" parent="CharacterPanel/ScrollContainer"]
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/h_separation = 10
theme_override_constants/v_separation = 10
columns = 5
size_flags_horizontal = 3
theme_override_constants/margin_right = 24
[node name="CharacterGridContainer" type="GridContainer" parent="CharacterPanel/ScrollContainer/ScrollbarGap"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 0
theme_override_constants/h_separation = 12
theme_override_constants/v_separation = 12
columns = 6
[node name="CloseTimer" type="Timer" parent="CharacterPanel"]
unique_name_in_owner = true
+12
View File
@@ -31,7 +31,19 @@ theme_type_variation = &"Title"
text = "Log"
horizontal_alignment = 1
[node name="EmptyLabel" type="Label" parent="LogPanel"]
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 80.0
offset_right = 680.0
offset_bottom = 110.0
theme_type_variation = &"TextLarge"
text = "Nothing has happened yet"
horizontal_alignment = 1
[node name="LogScrollContainer" type="ScrollContainer" parent="LogPanel"]
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 56.0
+15 -5
View File
@@ -17,16 +17,26 @@ panel = NodePath("QRPanel")
unique_name_in_owner = true
layout_mode = 0
offset_right = 450.0
offset_bottom = 520.0
offset_bottom = 540.0
theme_type_variation = &"PopupBackground"
[node name="TitleLabel" type="Label" parent="QRPanel" unique_id=1021365844]
layout_mode = 0
offset_top = 8.0
offset_right = 450.0
offset_bottom = 56.0
theme_type_variation = &"Title"
text = "Scan to search on your phone"
horizontal_alignment = 1
vertical_alignment = 1
[node name="QRCodeRect" type="TextureRect" parent="QRPanel" unique_id=1054378438]
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 20.0
offset_top = 60.0
offset_right = 430.0
offset_bottom = 430.0
offset_bottom = 470.0
script = ExtResource("2_jpsxt")
auto_update = true
mode = 4
@@ -47,9 +57,9 @@ metadata/_custom_type_script = "uid://dynulhnyq5hpq"
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 450.0
offset_top = 482.0
offset_right = 430.0
offset_bottom = 490.0
offset_bottom = 522.0
text = "URL"
horizontal_alignment = 1
vertical_alignment = 1
+53 -27
View File
@@ -16,37 +16,63 @@ panel = NodePath("SearchPanel")
unique_name_in_owner = true
layout_mode = 0
offset_right = 1152.0
offset_bottom = 640.0
offset_bottom = 880.0
theme_type_variation = &"PopupBackground"
[node name="ClearButton" type="Button" parent="." unique_id=1549439398]
[node name="TopRow" type="HBoxContainer" parent="SearchPanel" unique_id=1262945995]
layout_mode = 0
offset_left = 936.0
offset_top = 16.0
offset_right = 1027.0
offset_bottom = 56.0
scale = Vector2(2, 2)
focus_mode = 0
action_mode = 0
text = "Clear"
offset_left = 24.0
offset_top = 24.0
offset_right = 1128.0
offset_bottom = 80.0
theme_override_constants/separation = 16
[node name="Searchbar" type="TextEdit" parent="." unique_id=2112669372]
layout_mode = 0
offset_left = 32.0
offset_top = 16.0
offset_right = 468.0
offset_bottom = 56.0
scale = Vector2(2, 2)
placeholder_text = "Search"
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=213733486]
layout_mode = 0
offset_top = 128.0
offset_right = 576.0
offset_bottom = 384.0
scale = Vector2(2, 2)
[node name="SearchResults" type="VBoxContainer" parent="ScrollContainer" unique_id=1567403144]
[node name="SearchField" type="LineEdit" parent="SearchPanel/TopRow" unique_id=2112669372]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SearchField"
placeholder_text = "Search"
caret_blink = true
[node name="ClearButton" type="Button" parent="SearchPanel/TopRow" unique_id=1549439398]
unique_name_in_owner = true
custom_minimum_size = Vector2(140, 0)
layout_mode = 2
focus_mode = 0
theme_type_variation = &"LargeButton"
action_mode = 0
text = "Clear"
[node name="CountLabel" type="Label" parent="SearchPanel" unique_id=1262945996]
unique_name_in_owner = true
layout_mode = 0
offset_left = 24.0
offset_top = 90.0
offset_right = 1128.0
offset_bottom = 116.0
[node name="ScrollContainer" type="ScrollContainer" parent="SearchPanel" unique_id=213733486]
layout_mode = 0
offset_left = 24.0
offset_top = 124.0
offset_right = 1128.0
offset_bottom = 856.0
horizontal_scroll_mode = 0
[node name="SearchResults" type="VBoxContainer" parent="SearchPanel/ScrollContainer" unique_id=1567403144]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="EmptyLabel" type="Label" parent="SearchPanel" unique_id=1262945997]
unique_name_in_owner = true
visible = false
layout_mode = 0
offset_left = 24.0
offset_top = 160.0
offset_right = 1128.0
offset_bottom = 200.0
theme_type_variation = &"Title"
text = "No soundtracks found"
horizontal_alignment = 1
+235 -195
View File
@@ -15,241 +15,281 @@ panel = NodePath("SettingsPanel")
[node name="SettingsPanel" type="Panel" parent="." unique_id=45537159]
unique_name_in_owner = true
layout_mode = 0
offset_right = 500.0
offset_bottom = 640.0
offset_right = 520.0
offset_bottom = 270.0
theme_type_variation = &"PopupBackground"
[node name="ScrollContainer" type="ScrollContainer" parent="SettingsPanel" unique_id=1325795358]
[node name="VBoxContainer" type="VBoxContainer" parent="SettingsPanel" unique_id=1796635163]
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 8.0
offset_right = 480.0
offset_bottom = 632.0
horizontal_scroll_mode = 0
offset_top = 12.0
offset_right = 500.0
offset_bottom = 258.0
[node name="VBoxContainer" type="VBoxContainer" parent="SettingsPanel/ScrollContainer" unique_id=1796635163]
clip_contents = true
[node name="Label" type="Label" parent="SettingsPanel/VBoxContainer" unique_id=1111531402]
layout_mode = 2
size_flags_horizontal = 3
[node name="Label" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1111531402]
layout_mode = 2
size_flags_vertical = 0
theme_type_variation = &"Title"
text = "Settings"
horizontal_alignment = 1
[node name="StopAfterCurrentCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=508305575]
[node name="ServerRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer" unique_id=434350695]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ServerRow" unique_id=434350696]
layout_mode = 2
theme_type_variation = &"SettingsRowLabel"
text = "Server"
[node name="SelectServerButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/ServerRow" unique_id=434350694]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
button_pressed = true
action_mode = 0
text = "Stop after current"
focus_mode = 0
[node name="HideNextTrackCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1568244599]
[node name="FolderRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/FolderRow"]
layout_mode = 2
theme_type_variation = &"SettingsRowLabel"
text = "Music folder"
[node name="FolderLabel" type="Label" parent="SettingsPanel/VBoxContainer/FolderRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
button_pressed = true
action_mode = 0
text = "Hide next track"
mouse_filter = 1
text = "None, using the server"
text_overrun_behavior = 3
[node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1673750542]
[node name="ChooseFolderButton" type="Button" parent="SettingsPanel/VBoxContainer/FolderRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
action_mode = 0
text = "Turn on played to database"
[node name="LowPlayedCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1672657048]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
action_mode = 0
text = "Use low played mode"
[node name="HideBeginningCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1370617867]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
action_mode = 0
text = "Hide beginning of songs"
[node name="HideForSecondsHBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1368485120]
unique_name_in_owner = true
layout_mode = 2
[node name="HideForSecondsTitle" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=1822369383]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "Hide Start Seconds: "
[node name="HideForSecondsLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=1580108945]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "5.0"
horizontal_alignment = 1
[node name="LowerHideForSecondsButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=180643442]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "-1"
text = "Choose…"
[node name="IncreaseHideForSecondsButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=340681770]
[node name="UseServerButton" type="Button" parent="SettingsPanel/VBoxContainer/FolderRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
visible = false
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "+1"
text = "Use server"
[node name="HideLengthCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=67806372]
[node name="FolderErrorLabel" type="Label" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "No songs found"
text_overrun_behavior = 3
[node name="PlayOrderRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/PlayOrderRow"]
layout_mode = 2
size_flags_horizontal = 3
action_mode = 0
text = "Hide length of songs"
theme_type_variation = &"SettingsRowLabel"
text = "Play the prepared quiz"
[node name="HideTicksCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=706322752]
[node name="PlayOrderButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/PlayOrderRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
action_mode = 0
text = "Hide ticks in playbar"
[node name="HBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=2140610992]
layout_mode = 2
[node name="Label2" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=1892907487]
layout_mode = 2
size_flags_horizontal = 3
text = "Winning Score: "
[node name="ScoreLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=444542662]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "20"
horizontal_alignment = 1
[node name="LowerButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=614806355]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "-1"
[node name="IncreaseButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=1837169457]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "+1"
[node name="CacheHBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=658210492]
layout_mode = 2
[node name="CacheTitleLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=365012193]
layout_mode = 2
size_flags_horizontal = 3
text = "Predownload songs: "
[node name="CacheLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=1558370172]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "1"
horizontal_alignment = 1
[node name="LowerCacheButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=1990385038]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "-1"
[node name="IncreaseCacheButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=1806295533]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "+1"
[node name="InspirationHBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1686756540]
layout_mode = 2
[node name="InspirationListSpeedTitle" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=1397861506]
layout_mode = 2
size_flags_horizontal = 3
text = "Inspiration List Speed: "
[node name="InspirationListSpeedLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=867387668]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "1"
horizontal_alignment = 1
[node name="LowerInspirationSpeedButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=845209534]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "-1"
[node name="IncreaseInspirationSpeedButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=1122701153]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "+1"
[node name="SelectServerButton" type="OptionButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=434350694]
unique_name_in_owner = true
layout_mode = 2
selected = 0
item_count = 5
popup/item_0/text = "https://music.sanplex.xyz"
popup/item_0/id = 1
popup/item_1/text = "https://tmusic.sanplex.xyz"
popup/item_1/id = 2
popup/item_2/text = "https://rmusic.sanplex.xyz"
popup/item_2/id = 3
popup/item_3/text = "https://old-music.sanplex.xyz"
popup/item_3/id = 4
popup/item_4/text = "http://localhost:8080"
popup/item_4/id = 5
item_count = 2
popup/item_0/text = "In order"
popup/item_0/id = 0
popup/item_1/text = "At random"
popup/item_1/id = 1
[node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=400304684]
[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]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Fullscreen"
[node name="QuickSyncButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=49497448]
visible = false
layout_mode = 2
size_flags_horizontal = 3
button_pressed = true
action_mode = 0
text = "Quick sync"
[node name="LocalButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=905844805]
[node name="ReduceAnimationsButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Reduce animations"
[node name="CacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer" unique_id=658210492]
layout_mode = 2
[node name="CacheTitleLabel" type="Label" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=365012193]
layout_mode = 2
size_flags_horizontal = 3
disabled = true
text = "Local"
theme_type_variation = &"SettingsRowLabel"
text = "Songs to download ahead"
[node name="CacheLabel" type="Label" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1558370172]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "1"
horizontal_alignment = 1
[node name="LowerCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1990385038]
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 = "-1"
[node name="IncreaseCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1806295533]
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 = "+1"
[node name="RandomModeRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
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
selected = 0
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"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Count plays in statistics"
[node name="ConfigFolderRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ConfigFolderRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Settings and cached files"
[node name="OpenConfigFolderButton" type="Button" parent="SettingsPanel/VBoxContainer/ConfigFolderRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
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="."]
unique_name_in_owner = true
title = "Choose a music folder"
initial_position = 2
size = Vector2i(800, 500)
ok_button_text = "Select Current Folder"
file_mode = 2
access = 2
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
+17 -24
View File
@@ -3,7 +3,6 @@
[ext_resource type="Script" uid="uid://rolypd0cqd6h" path="res://popups/shortcuts_popup.gd" id="1_12pxi"]
[node name="ShortcutsPopup" type="Control" node_paths=PackedStringArray("panel")]
custom_minimum_size = Vector2(700, 700)
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
@@ -15,35 +14,29 @@ panel = NodePath("ShortcutsPanel")
[node name="ShortcutsPanel" type="Panel" parent="."]
unique_name_in_owner = true
custom_minimum_size = Vector2(700, 700)
layout_mode = 0
offset_right = 40.0
offset_bottom = 40.0
offset_right = 700.0
offset_bottom = 706.0
theme_type_variation = &"PopupBackground"
[node name="ScrollContainer" type="ScrollContainer" parent="ShortcutsPanel"]
custom_minimum_size = Vector2(700, 700)
[node name="TitleLabel" type="Label" parent="ShortcutsPanel"]
layout_mode = 0
offset_right = 40.0
offset_bottom = 40.0
offset_right = 700.0
offset_bottom = 56.0
theme_type_variation = &"Title"
text = "Shortcuts"
horizontal_alignment = 1
vertical_alignment = 1
[node name="ScrollContainer" type="ScrollContainer" parent="ShortcutsPanel"]
layout_mode = 0
offset_left = 24.0
offset_top = 60.0
offset_right = 676.0
offset_bottom = 690.0
horizontal_scroll_mode = 0
[node name="shortcutsVBox" type="VBoxContainer" parent="ShortcutsPanel/ScrollContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(700, 700)
layout_mode = 2
[node name="HBoxContainer" type="HBoxContainer" parent="ShortcutsPanel/ScrollContainer/shortcutsVBox"]
layout_mode = 2
alignment = 1
[node name="Label" type="Label" parent="ShortcutsPanel/ScrollContainer/shortcutsVBox/HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
text = "testar"
horizontal_alignment = 1
[node name="Label2" type="Label" parent="ShortcutsPanel/ScrollContainer/shortcutsVBox/HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
text = "testera 2"
horizontal_alignment = 1
+256 -10
View File
@@ -30,19 +30,87 @@ theme_type_variation = &"Title"
horizontal_alignment = 1
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
layout_mode = 0
offset_left = 20.0
offset_top = 54.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_top = 280.0
offset_right = 680.0
offset_bottom = 784.0
horizontal_scroll_mode = 0
@@ -51,3 +119,181 @@ horizontal_scroll_mode = 0
unique_name_in_owner = true
layout_mode = 2
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"]
[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")]
layout_mode = 3
@@ -62,11 +63,11 @@ layout_mode = 2
[node name="SyncProgressBar" type="ProgressBar" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer" unique_id=719135231]
unique_name_in_owner = true
custom_minimum_size = Vector2(1300, 0)
custom_minimum_size = Vector2(1300, 32)
layout_mode = 2
size_flags_horizontal = 6
step = 1.0
rounded = true
theme_type_variation = &"SyncProgressBar"
script = ExtResource("progress_bar_script")
[node name="SyncHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=1156646398]
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")
+4 -1
View File
@@ -71,8 +71,11 @@ func open() -> void:
whats_new_label.text = changelog_text(entries)
# Asked every time, as another server can be picked in the settings
server_version_label.text = "Loading…"
server_history_label.text = ""
if Settings.is_offline():
server_version_label.text = "Not connected, playing from a music folder"
return
server_version_label.text = "Loading…"
MusicServerClient.fetch_version_history(_show_server_history, _show_server_history_failed)
+51 -4
View File
@@ -4,32 +4,79 @@ extends Control
##
## Clicking outside [member panel] closes it. Popups that need to prepare or clean up
## override [method open] or [method close] and call [code]super()[/code].
##
## The panel fades in while growing from [constant START_SCALE] of its size, and closing
## does the reverse; the popup is hidden once that has finished. See [Anim].
## Emitted when the popup opens, also when it is opened again while closing.
signal opening
## Emitted when the popup starts closing, before it is hidden, so the backdrop can fade
## out with it.
signal closing
## How big the panel starts when it opens, and ends when it closes.
const START_SCALE: float = 0.95
## The visible part of the popup. Clicks outside it close the popup.
@export var panel: Control
## True while the popup is fading out after [method close].
var is_closing: bool = false
## Shows the popup.
func open() -> void:
is_closing = false
visible = true
opening.emit()
var open_tween: Tween = Anim.tween(panel, &"popup")
panel.modulate.a = 1.0
panel.scale = Vector2.ONE
if open_tween == null:
return
Anim.center_pivot(panel)
panel.modulate.a = 0.0
panel.scale = Vector2.ONE * START_SCALE
open_tween.set_parallel()
open_tween.tween_property(panel, "modulate:a", 1.0, Anim.NORMAL)
open_tween.tween_property(panel, "scale", Vector2.ONE, Anim.NORMAL) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
## Hides the popup.
func close() -> void:
visible = false
if !visible || is_closing:
return
is_closing = true
closing.emit()
var close_tween: Tween = Anim.tween(panel, &"popup")
if close_tween == null:
_hide()
return
Anim.center_pivot(panel)
close_tween.set_parallel()
close_tween.tween_property(panel, "modulate:a", 0.0, Anim.FAST)
close_tween.tween_property(panel, "scale", Vector2.ONE * START_SCALE, Anim.FAST)
close_tween.chain().tween_callback(_hide)
## Opens the popup if it is closed and closes it if it is open.
func toggle() -> void:
if visible:
if visible && !is_closing:
close()
else:
open()
func _hide() -> void:
is_closing = false
visible = false
panel.modulate.a = 1.0
panel.scale = Vector2.ONE
func _input(event: InputEvent) -> void:
if !visible || !(event is InputEventMouseButton) || !event.pressed:
if !visible || is_closing || !(event is InputEventMouseButton) || !event.pressed:
return
var click: InputEventMouseButton = panel.make_input_local(event)
if !Rect2(Vector2.ZERO, panel.size).has_point(click.position):
+54 -37
View File
@@ -1,48 +1,65 @@
class_name CharacterCard
extends Control
extends Panel
## One character in the character select popup: its picture and name. Clicking it picks
## 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, character_name: String)
signal new_character_selected(img_texture: ImageTexture)
## The character's name: its picture's file name, e.g. "PaperMario.png".
var character_name: String = ""
@onready var panel: Panel = $Panel
@onready var character_image: TextureRect = %CharacterTextureRect
@onready var character_name_label: Label = %CharacterName
func _input(event: InputEvent) -> void:
if visible && Settings.character_select_open:
if event is InputEventMouseButton && event.is_pressed() \
&& event.get_button_index() == MOUSE_BUTTON_LEFT:
var local_event: InputEvent = make_input_local(event)
if Rect2(Vector2(0, 0), panel.size).has_point(local_event.position):
func _ready() -> void:
gui_input.connect(_on_gui_input)
mouse_entered.connect(_set_hovered.bind(true))
mouse_exited.connect(_set_hovered.bind(false))
## 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.
## [method CharacterCache.fetch_image].
func set_character_name(new_name: String, fetch_image: Callable) -> void:
character_name = new_name
character_name_label.text = display_name(new_name)
tooltip_text = character_name_label.text
var image_fetched: Callable = func image_fetched(picture: Image) -> void:
character_image.texture = ImageTexture.create_from_image(picture)
fetch_image.call(new_name, image_fetched)
## The name to show for a picture's [param file_name]: without the file extension, and
## with words that are written together split, e.g. "PaperMario.png" is "Paper Mario".
static func display_name(file_name: String) -> String:
var name_part: String = file_name.get_basename() if "." in file_name else file_name
var words: RegEx = RegEx.create_from_string("([a-z])([A-Z])")
return words.sub(name_part, "$1 $2", true)
## Reads a PNG, JPG or WebP picture, telling them apart by their first bytes so that no
## error is printed for trying the wrong format. Returns null if it is none of them.
static func image_from_bytes(bytes: PackedByteArray) -> Image:
var image: Image = Image.new()
var error: Error = ERR_FILE_UNRECOGNIZED
if bytes.size() >= 4 && bytes.slice(0, 4) == PackedByteArray([0x89, 0x50, 0x4E, 0x47]):
error = image.load_png_from_buffer(bytes)
elif bytes.size() >= 3 && bytes.slice(0, 3) == PackedByteArray([0xFF, 0xD8, 0xFF]):
error = image.load_jpg_from_buffer(bytes)
elif bytes.size() >= 12 && bytes.slice(8, 12).get_string_from_ascii() == "WEBP":
error = image.load_webp_from_buffer(bytes)
return image if error == OK else null
func _set_hovered(hovered: bool) -> void:
theme_type_variation = &"CharacterCardHover" if hovered else &"CharacterCard"
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton && event.pressed \
&& event.button_index == MOUSE_BUTTON_LEFT && Settings.character_select_open:
accept_event()
GameLog.debug(character_name_label.text)
new_character_selected.emit(character_image.texture)
## Shows [param character_name] and fetches its picture from the server.
func set_character_name(character_name: String) -> void:
panel.size = Vector2(240, 145)
character_name_label.text = character_name.split(".")[0]
character_name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
var image_fetched: Callable = func image_fetched(image: PackedByteArray) -> void:
var img: Image = Image.new()
var err: Error = img.load_png_from_buffer(image)
if err != OK:
GameLog.debug("Character image is not a PNG, trying JPG: ", error_string(err))
var err2: Error = img.load_jpg_from_buffer(image)
if err2 != OK:
GameLog.warning("Could not load the image for ", character_name, ": ",
error_string(err2))
var new_image: ImageTexture = ImageTexture.new()
new_image.set_image(img)
character_image.custom_minimum_size = Vector2(200, 100)
character_image.size = Vector2(200, 100)
character_image.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
character_image.size_flags_vertical = Control.SIZE_SHRINK_CENTER
character_image.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
character_image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
character_image.texture = new_image
MusicServerClient.fetch_character_image(character_name, image_fetched)
new_character_selected.emit(character_image.texture, character_name)
+34 -12
View File
@@ -1,11 +1,24 @@
class_name CharacterSelectPopup
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.
signal character_selected(texture: ImageTexture)
## Emitted with the picture and name of the character that was picked.
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 character: PackedScene = preload("res://popups/CharacterCard.tscn")
## Keeps the pictures on disk, so only changed ones are downloaded.
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 title_label: Label = %TitleLabel
@@ -23,21 +36,30 @@ func _on_close_timeout() -> void:
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:
GameLog.debug("load_characters")
var fetch_character_list: Callable = func fetch_character_list(list: Variant) -> void:
GameLog.debug("fetch_character_list")
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:
GameLog.debug("list_loaded")
# The source changed again before the server answered
if source != loading:
return
Settings.delete_children(character_grid)
if typeof(list) == TYPE_ARRAY:
for character_name: String in list:
for character_name: String in names:
GameLog.debug("character_name ", character_name)
var character_box: CharacterCard = character.instantiate()
character_grid.add_child(character_box)
character_box.custom_minimum_size = Vector2(235, 145)
character_box.size = Vector2(235, 145)
character_box.set_character_name(character_name)
character_box.set_character_name(character_name, loading.fetch_image)
character_box.new_character_selected.connect(_on_character_selected)
MusicServerClient.fetch_character_list(fetch_character_list)
loading.load_list(list_loaded)
if changed:
source_changed.emit()
func show_grid(player_name: String) -> void:
@@ -54,12 +76,12 @@ func close() -> void:
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")
# 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
visible = false
if can_select_character:
character_selected.emit(texture)
character_selected.emit(texture, character_name)
close_timer.start()
can_select_character = false
+24 -14
View File
@@ -1,28 +1,38 @@
class_name LogPopup
extends BasePopup
## The game log: a row with the time for each thing that happens in the quiz, such as a
## player getting a point. Opened with Alt+L.
var log_rows: Array
## player getting a point. Opened with Alt+L. The newest row is at the bottom and is
## scrolled into view.
@onready var log_panel: Panel = %LogPanel
@onready var log_box: VBoxContainer = %LogVBoxContainer
@onready var log_scroll: ScrollContainer = %LogScrollContainer
@onready var empty_label: Label = %EmptyLabel
func _ready() -> void:
visibility_changed.connect(_on_visibility_changed)
func add_log_row(text: String) -> void:
var log_row: String = get_time() + text
log_rows.append(log_row)
log_box.add_child(make_log_label(log_row))
func make_log_label(row: String) -> Label:
var log_label: Label = Label.new()
log_label.theme_type_variation = &"TextLarge"
log_label.text = row
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD
return log_label
var row: int = log_box.get_child_count()
log_box.add_child(ListRow.create(get_time() + text, row, &"TextLarge"))
empty_label.visible = false
_scroll_to_newest.call_deferred()
func get_time() -> String:
var time: Dictionary = Time.get_time_dict_from_system()
return "%02d:%02d:%02d " % [time.hour, time.minute, time.second]
func _on_visibility_changed() -> void:
if visible:
empty_label.visible = log_box.get_child_count() == 0
_scroll_to_newest.call_deferred()
# Deferred, as the list only knows its new height once the new row is laid out
func _scroll_to_newest() -> void:
await get_tree().process_frame
log_scroll.scroll_vertical = int(log_scroll.get_v_scroll_bar().max_value)
+29
View File
@@ -23,9 +23,38 @@ static func add_to(parent: Control, popups: Array[BasePopup]) -> PopupBackdrop:
for popup: BasePopup in popups:
parent.move_child(popup, -1)
popup.visibility_changed.connect(backdrop._update)
popup.closing.connect(backdrop._on_popup_closing)
popup.opening.connect(backdrop._on_popup_opening)
backdrop._update()
return backdrop
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:
var was_visible: bool = visible
visible = _popups.any(func(popup: BasePopup) -> bool: return popup.visible)
if visible && !was_visible:
Anim.fade_in(self)
# A popup opened again while closing stops the backdrop fading out
func _on_popup_opening() -> void:
var fade_tween: Tween = Anim.tween(self, &"fade")
if fade_tween == null || modulate.a >= 1.0:
modulate.a = 1.0
return
fade_tween.tween_property(self, "modulate:a", 1.0, Anim.FAST)
# Fades out with the last open popup, which hides the backdrop when it has closed
func _on_popup_closing() -> void:
if _popups.any(func(popup: BasePopup) -> bool: return popup.visible && !popup.is_closing):
return
var fade_tween: Tween = Anim.tween(self, &"fade")
if fade_tween != null:
fade_tween.tween_property(self, "modulate:a", 0.0, Anim.FAST)
+10 -13
View File
@@ -9,21 +9,18 @@ extends BasePopup
func _ready() -> void:
qr_code_rect.light_module_color = Color.WHITE
qr_code_rect.dark_module_color = Color.BLACK
qr_code_rect.quiet_zone_size = 4
# Generate QR code for the search URL
var url: String = Settings.default_path + "/search"
qr_code_rect.data = url
url_label.text = qr_code_rect.data
GameLog.debug("QR code generated for: ", url)
_show_search_url()
func open() -> void:
super()
# Regenerate QR code in case settings changed
var url: String = Settings.default_path + "/search"
qr_code_rect.data = url
url_label.text = qr_code_rect.data
# Another server may have been picked in the settings since last time
_show_search_url()
func _show_search_url() -> void:
var url: String = Settings.default_path + "/search"
if qr_code_rect.data != url:
qr_code_rect.data = url
GameLog.debug("QR code generated for: ", url)
url_label.text = url
+52 -4
View File
@@ -15,18 +15,57 @@ const MARGIN_BOTTOM: float = 44.0
## About how many song numbers fit along the bottom.
const MAX_SONG_LABELS: int = 20
## How much of the lines is drawn, from 0 (none) to 1 (all), so they can be drawn from
## left to right.
var progress: float = 1.0:
set(value):
progress = value
queue_redraw()
var _history: ScoreHistory
var _winning_score: int = 0
func _notification(what: int) -> void:
# The colours come from the theme, which can be switched in Settings
if what == NOTIFICATION_THEME_CHANGED:
queue_redraw()
## Shows [param history]. The y axis goes up to [param winning_score], or higher if
## someone has more points.
## someone has more points. A winning score of 0 draws no winning line.
func show_history(history: ScoreHistory, winning_score: int) -> void:
_history = history
_winning_score = winning_score
queue_redraw()
## Draws the lines from left to right over [param duration] seconds.
func draw_lines_in(duration: float = 1.2) -> void:
var draw_tween: Tween = Anim.tween(self, &"draw")
progress = 1.0
if draw_tween == null:
return
progress = 0.0
draw_tween.tween_property(self, "progress", 1.0, duration) \
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
## The part of [param points] left of [param limit_x], cut where the line crosses it.
static func clip_line(points: PackedVector2Array, limit_x: float) -> PackedVector2Array:
var clipped: PackedVector2Array = []
for i: int in points.size():
if points[i].x <= limit_x:
clipped.append(points[i])
continue
if i > 0 && points[i - 1].x < limit_x:
var before: Vector2 = points[i - 1]
var weight: float = (limit_x - before.x) / (points[i].x - before.x)
clipped.append(before.lerp(points[i], weight))
break
return clipped
## The colour of the player at [param index] in the player list.
func player_color(index: int) -> Color:
return get_theme_color(StringName("player_%d" % (index % 7 + 1)), THEME_TYPE)
@@ -57,13 +96,20 @@ func _draw() -> void:
var line_width: int = get_theme_constant(&"line_width", THEME_TYPE)
var dot_radius: int = get_theme_constant(&"dot_radius", THEME_TYPE)
var limit_x: float = lerpf(MARGIN_LEFT, size.x - MARGIN_RIGHT, progress)
for index: int in _history.players.size():
var color: Color = player_color(index)
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
draw_polyline(line_points(index), color, line_width, true)
var points: PackedVector2Array = clip_line(line_points(index), limit_x)
if points.size() >= 2:
draw_polyline(points, color, line_width, true)
for song: int in range(1, scores.size()):
if scores[song] > scores[song - 1]:
draw_circle(_point(song, scores[song], index), dot_radius, color)
var dot: Vector2 = _point(song, scores[song], index)
if scores[song] > scores[song - 1] && dot.x <= limit_x:
draw_circle(dot, dot_radius, color)
# The final scores are written once the lines have reached them
if progress < 1.0:
return
var label_positions: Array[Vector2] = score_label_positions()
for index: int in _history.players.size():
draw_string(font, label_positions[index],
@@ -117,6 +163,8 @@ func _draw_score_axis(font: Font, font_size: int, text_color: Color) -> void:
draw_line(Vector2(MARGIN_LEFT, y), Vector2(size.x - MARGIN_RIGHT, y), grid_color, 1)
draw_string(font, Vector2(0, y + font_size * 0.35), str(score),
HORIZONTAL_ALIGNMENT_RIGHT, MARGIN_LEFT - 8, font_size, text_color)
if _winning_score <= 0:
return
var winning_color: Color = get_theme_color(&"winning_line", THEME_TYPE)
var winning_y: float = _y(_winning_score)
draw_dashed_line(Vector2(MARGIN_LEFT, winning_y), Vector2(size.x - MARGIN_RIGHT, winning_y),
+155 -91
View File
@@ -1,29 +1,34 @@
class_name SearchPopup
extends BasePopup
## Searches every soundtrack on the server as you type, with [SoundtrackSearch].
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
## 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.
var soundtracks: Array = []
var label_pool: Array[Label] = []
var _matcher: SoundtrackSearch = SoundtrackSearch.new()
# One row per soundtrack, in the order of soundtracks. Rows are reused between searches.
var _rows: Array[PanelContainer] = []
var _index_of: Dictionary = {}
# The other names of each soundtrack, by its real name
var _alternatives: Dictionary = {}
@onready var search_list: VBoxContainer = %SearchResults
@onready var search_panel: Panel = %SearchPanel
@onready var clear_button: Button = $ClearButton
@onready var search_bar: TextEdit = $Searchbar
@onready var search_field: LineEdit = %SearchField
@onready var clear_button: Button = %ClearButton
@onready var count_label: Label = %CountLabel
@onready var empty_label: Label = %EmptyLabel
@onready var search_results: VBoxContainer = %SearchResults
func _ready() -> void:
clear_button.pressed.connect(clear)
search_bar.grab_focus()
search_bar.text_changed.connect(search)
visibility_changed.connect(focus)
func focus() -> void:
if visible:
search_bar.grab_focus()
clear()
search_field.text_changed.connect(func(_text: String) -> void: search())
visibility_changed.connect(_on_visibility_changed)
func close() -> void:
@@ -31,94 +36,153 @@ func close() -> void:
super()
## Empties the search field and shows every soundtrack.
func clear() -> void:
search_field.text = ""
search()
search_field.grab_focus()
## Shows the soundtracks matching the search field, best matches first, or all of them
## when it is empty.
func search() -> void:
GameLog.debug(search_bar.text)
var search_text: String = search_bar.text
if search_text == "":
show_all_soundtracks()
return
perform_search(search_text)
func show_all_soundtracks() -> void:
# Hide all visible labels and add to pool
for child: Label in search_list.get_children():
if child.visible:
child.visible = false
label_pool.append(child)
# Show all soundtracks in order
for soundtrack: String in soundtracks:
add_soundtrack_reuse(soundtrack)
func perform_search(search_text: String) -> void:
# Hide all visible labels and add to pool
for child: Label in search_list.get_children():
if child.visible:
child.visible = false
label_pool.append(child)
for soundtrack: String in _matcher.find_matches(search_text):
add_soundtrack_reuse(soundtrack)
func add_soundtrack(soundtrack: String) -> void:
# Backward compatibility - delegates to reuse version
add_soundtrack_reuse(soundtrack)
## Shows [param soundtrack], reusing a hidden label from the pool when there is one.
func add_soundtrack_reuse(soundtrack: String) -> void:
var label: Label
if label_pool.size() > 0:
label = label_pool.pop_front()
label.text = soundtrack
label.visible = true
# Move to end to maintain display order
search_list.move_child(label, search_list.get_child_count() - 1)
var search_text: String = search_field.text.strip_edges()
var shown: Array[int] = []
if search_text.is_empty():
shown.assign(range(soundtracks.size()))
else:
label = Label.new()
label.text = soundtrack
label.autowrap_mode = TextServer.AUTOWRAP_WORD
search_list.add_child(label)
label.visible = true
for soundtrack: String in _matcher.find_matches(search_text):
shown.append(_index_of[soundtrack])
var is_shown: Array[bool] = []
is_shown.resize(_rows.size())
for position: int in shown.size():
var row: PanelContainer = _rows[shown[position]]
if row.get_index() != position:
search_results.move_child(row, position)
# Rows that come back with the search fade in
if !row.visible:
Anim.fade_in(row, Anim.FAST)
row.visible = true
row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven"
_show_name(row, soundtracks[shown[position]], search_text)
is_shown[shown[position]] = true
for index: int in _rows.size():
if !is_shown[index]:
_rows[index].visible = false
empty_label.visible = shown.is_empty() && !soundtracks.is_empty()
if search_text.is_empty():
count_label.text = "%d soundtracks" % soundtracks.size()
else:
count_label.text = "%d of %d soundtracks" % [shown.size(), soundtracks.size()]
func check_if_soundtrack_exists(soundtrack: String) -> bool:
var soundtrack_exists: bool = false
for child: Label in search_list.get_children():
if child.text == soundtrack and child.visible:
soundtrack_exists = true
return soundtrack_exists
## The names of the soundtracks shown, in the order they are shown.
func shown_names() -> Array[String]:
var names: Array[String] = []
for row: PanelContainer in search_results.get_children():
if row.visible:
names.append(row.get_meta(&"soundtrack"))
return names
func get_list_of_soundtracks() -> void:
GameLog.debug("get_list_of_soundtracks")
var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void:
if typeof(array) == TYPE_ARRAY:
# Reuse existing labels: hide all and add to pool
for child: Label in search_list.get_children():
child.visible = false
label_pool.append(child)
set_soundtracks(array)
for soundtrack: String in soundtracks:
add_soundtrack_reuse(soundtrack)
else:
GameLog.warning("Unexpected data")
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
if Settings.is_offline():
set_soundtracks(Settings.music_folder.soundtrack_names())
return
var with_names: Callable = func with_names(list: Array[Soundtrack]) -> void:
var names: Array[String] = []
var alternatives: Dictionary = {}
for soundtrack: Soundtrack in list:
names.append(soundtrack.name)
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.
func set_soundtracks(list: Array) -> void:
## Replaces the searchable soundtracks and shows them, keeping the current search.
## [param alternatives] has the other names of a soundtrack, by its real name.
func set_soundtracks(list: Array, alternatives: Dictionary = {}) -> void:
soundtracks = []
soundtracks.append_array(list)
_matcher.set_names(soundtracks)
_alternatives = alternatives
_matcher.set_names(soundtracks, alternatives)
_index_of.clear()
for index: int in range(soundtracks.size() - 1, -1, -1):
_index_of[soundtracks[index]] = index
while _rows.size() < soundtracks.size():
var row: PanelContainer = _make_row()
search_results.add_child(row)
_rows.append(row)
while _rows.size() > soundtracks.size():
_rows.pop_back().free()
for index: int in soundtracks.size():
_rows[index].set_meta(&"soundtrack", soundtracks[index])
search()
func clear() -> void:
search_bar.text = ""
show_all_soundtracks()
search_bar.grab_focus()
## [param soundtrack_name] as BBCode, with the letters [param search_text] matched in
## [param highlight_color].
static func highlighted_name(
soundtrack_name: String, search_text: String, highlight_color: Color) -> String:
var matched: Array[int] = SoundtrackSearch.matched_letters(soundtrack_name, search_text)
var text: String = ""
var in_highlight: bool = false
for i: int in soundtrack_name.length():
var highlight: bool = matched.has(i)
if highlight != in_highlight:
text += "[color=#%s]" % highlight_color.to_html(false) if highlight else "[/color]"
in_highlight = highlight
var letter: String = soundtrack_name[i]
text += "[lb]" if letter == "[" else letter
if in_highlight:
text += "[/color]"
return text
func _make_row() -> PanelContainer:
var row: PanelContainer = PanelContainer.new()
var label: RichTextLabel = RichTextLabel.new()
label.theme_type_variation = &"SearchResult"
label.bbcode_enabled = true
label.fit_content = true
label.scroll_active = false
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(label)
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:
var highlight: Color = 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)
# Setting the same text again would make the label lay it out again for nothing
if label.text != text:
label.text = text
func _on_visibility_changed() -> void:
if visible:
clear()
+225 -142
View File
@@ -1,154 +1,202 @@
class_name SettingsPopup
extends BasePopup
## The settings: how songs are shown and played, the winning score, the song cache,
## which MusicServer to use and fullscreen.
## The application settings: which MusicServer or music folder to play from, the theme,
## fullscreen, fewer animations, how many songs to download ahead, how random songs are
## picked and whether plays count in the statistics, and a button that opens the folder
## the settings are kept in. Opened from the Settings button in the top bar.
##
## 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.
## 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].
## Emitted when another MusicServer is picked.
## Emitted when another MusicServer or music folder is picked, or a prepared quiz's play
## order is changed. A new game starts.
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.
const PADDING: Vector2 = Vector2(20, 12)
@onready var settings_panel: Panel = %SettingsPanel
@onready var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton
@onready var hide_next_track_button: CheckButton = %HideNextTrackCheckButton
@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
@onready var low_played_button: CheckButton = %LowPlayedCheckButton
@onready var hide_beginning_button: CheckButton = %HideBeginningCheckButton
@onready var hide_length_button: CheckButton = %HideLengthCheckButton
@onready var hide_ticks_button: CheckButton = %HideTicksCheckButton
@onready var score_label: Label = %ScoreLabel
@onready var lower_winning_score_button: Button = %LowerButton
@onready var increase_winning_score_button: Button = %IncreaseButton
@onready var select_server_button: OptionButton = %SelectServerButton
@onready var fullscreen_button: CheckButton = %FullscreenButton
@onready var reduce_animations_button: CheckButton = %ReduceAnimationsButton
@onready var theme_button: OptionButton = %ThemeButton
@onready var cache_label: Label = %CacheLabel
@onready var lower_cache_button: Button = %LowerCacheButton
@onready var increase_cache_button: Button = %IncreaseCacheButton
@onready var inspiration_speed_label: Label = %InspirationListSpeedLabel
@onready var lower_inspiration_speed_button: Button = %LowerInspirationSpeedButton
@onready var increase_inspiration_speed_button: Button = %IncreaseInspirationSpeedButton
@onready var hide_for_seconds_label: Label = %HideForSecondsLabel
@onready var lower_hide_for_seconds_button: Button = %LowerHideForSecondsButton
@onready var increase_hide_for_seconds_button: Button = %IncreaseHideForSecondsButton
@onready var select_server_button: OptionButton = %SelectServerButton
@onready var fullscreen_button: CheckButton = %FullscreenButton
@onready var local_button: CheckButton = %LocalButton
@onready var random_mode_button: OptionButton = %RandomModeButton
@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
@onready var rows: VBoxContainer = %VBoxContainer
@onready var folder_label: Label = %FolderLabel
@onready var choose_folder_button: Button = %ChooseFolderButton
@onready var use_server_button: Button = %UseServerButton
@onready var folder_error_label: Label = %FolderErrorLabel
@onready var play_order_row: HBoxContainer = %PlayOrderRow
@onready var play_order_button: OptionButton = %PlayOrderButton
@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 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
## opens while they run.
var show_folder: Callable = _show_in_file_manager
# Where the panel's middle is, so it stays centred when rows are shown or hidden
var _panel_middle_y: float = 0.0
func _ready() -> void:
for server: String in Settings.servers:
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
select_server_button.selected = Settings.selected_server
select_server_button.select(Settings.selected_server)
cache_label.text = str(Playlist.number_of_tracks_to_preload)
stop_after_current_button.pressed.connect(stop_after_current)
hide_next_track_button.pressed.connect(hide_next_track)
add_to_database_button.pressed.connect(add_to_database)
low_played_button.pressed.connect(low_played)
hide_beginning_button.pressed.connect(hide_beginning)
hide_length_button.pressed.connect(hide_length)
hide_ticks_button.pressed.connect(hide_ticks)
lower_winning_score_button.pressed.connect(lower_winning_score)
increase_winning_score_button.pressed.connect(increase_winning_score)
fullscreen_button.pressed.connect(fullscreen)
reduce_animations_button.button_pressed = Settings.reduce_animations
reduce_animations_button.toggled.connect(set_reduce_animations)
choose_folder_button.pressed.connect(folder_dialog.popup_centered)
folder_dialog.dir_selected.connect(use_music_folder)
use_server_button.pressed.connect(use_server)
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.tooltip_text = Settings.config_dir()
clear_cache_button.pressed.connect(clear_cache)
play_order_button.item_selected.connect(_on_play_order_selected)
lower_cache_button.pressed.connect(lower_cache)
increase_cache_button.pressed.connect(increase_cache)
lower_inspiration_speed_button.pressed.connect(lower_inspiration_speed)
increase_inspiration_speed_button.pressed.connect(increase_inspiration_speed)
lower_hide_for_seconds_button.pressed.connect(lower_hide_for_seconds)
increase_hide_for_seconds_button.pressed.connect(increase_hide_for_seconds)
fullscreen_button.pressed.connect(fullscreen)
local_button.pressed.connect(local_play)
stop_after_current_button.button_pressed = Settings.stop_after_current
hide_next_track_button.button_pressed = Settings.hide_next_track
add_to_database_button.button_pressed = Settings.add_to_stats
low_played_button.button_pressed = Settings.use_low_played_mode
hide_beginning_button.button_pressed = Settings.hide_beginning
hide_length_button.button_pressed = Settings.hide_length
hide_ticks_button.button_pressed = Settings.hide_ticks
score_label.text = str(Settings.winning_score)
cache_label.text = str(Playlist.number_of_tracks_to_preload)
inspiration_speed_label.text = str(Settings.inspiration_list_speed)
hide_for_seconds_label.text = str(Settings.hide_for_seconds)
fullscreen_button.button_pressed = Settings.fullscreen
update_hide_for_seconds_enabled()
select_server_button.select(Settings.selected_server)
select_server_button.item_selected.connect(select_server)
random_mode_button.item_selected.connect(
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)
visibility_changed.connect(_on_visibility_changed)
# Alt+Enter switches fullscreen from the main window, which resizes the window
get_window().size_changed.connect(_update_fullscreen_button)
_panel_middle_y = settings_panel.position.y + settings_panel.size.y / 2
_show_music_source()
# Settings can change outside this popup, e.g. winning turns statistics off
func _on_visibility_changed() -> void:
if visible:
add_to_database_button.button_pressed = Settings.add_to_stats
_update_fullscreen_button()
## 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)
func _update_fullscreen_button() -> void:
fullscreen_button.set_pressed_no_signal(Settings.fullscreen)
## Turns every animation off or on, and saves it.
func set_reduce_animations(on: bool) -> void:
Settings.reduce_animations = on
Settings.save_settings()
func fullscreen() -> void:
Settings.fullscreen = !Settings.fullscreen
if Settings.fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
if !Settings.fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
Settings.set_fullscreen(!Settings.fullscreen)
func local_play() -> void:
Settings.play_local = !Settings.play_local
## Plays songs from the folder at [param path] instead of the server's. A folder with
## nothing to play is not used, and the popup says so.
func use_music_folder(path: String) -> void:
var folder: MusicFolder = MusicFolder.scan(path)
if folder.kind == MusicFolder.Kind.EMPTY:
folder_error_label.text = "No songs found in " + folder.display_name()
folder_error_label.visible = true
_fit_to_rows()
return
folder.set_play_order(play_order_button.selected as MusicFolder.PlayOrder)
Settings.music_folder = folder
_show_music_source()
server_changed.emit()
func stop_after_current() -> void:
Settings.stop_after_current = !Settings.stop_after_current
## Plays from the server again instead of the music folder.
func use_server() -> void:
Settings.music_folder = null
_show_music_source()
server_changed.emit()
func hide_next_track() -> void:
Settings.hide_next_track = !Settings.hide_next_track
## Shows the folder with the config file, the saved settings and the cached characters
## and songs in the system's file manager.
func open_config_folder() -> void:
var dir: String = Settings.config_dir()
DirAccess.make_dir_recursive_absolute(dir)
show_folder.call(dir)
func add_to_database() -> void:
Settings.add_to_stats = !Settings.add_to_stats
## 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
func low_played() -> void:
Settings.use_low_played_mode = !Settings.use_low_played_mode
## 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
func hide_beginning() -> void:
Settings.hide_beginning = !Settings.hide_beginning
update_hide_for_seconds_enabled()
## 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())
func update_hide_for_seconds_enabled() -> void:
var enabled: bool = Settings.hide_beginning
%HideForSecondsHBoxContainer.visible = enabled
## [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))
func hide_length() -> void:
Settings.hide_length = !Settings.hide_length
## 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()
func hide_ticks() -> void:
Settings.hide_ticks = !Settings.hide_ticks
func lower_winning_score() -> void:
Settings.winning_score = max(Settings.winning_score - 1, 1)
score_label.text = str(Settings.winning_score)
func increase_winning_score() -> void:
Settings.winning_score += 1
score_label.text = str(Settings.winning_score)
func set_winning_score(new_score: int) -> void:
Settings.winning_score = new_score
score_label.text = str(Settings.winning_score)
## 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:
Playlist.number_of_tracks_to_preload -= 1
if Playlist.number_of_tracks_to_preload < 1:
Playlist.number_of_tracks_to_preload = 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)
@@ -157,52 +205,87 @@ func increase_cache() -> void:
cache_label.text = str(Playlist.number_of_tracks_to_preload)
func lower_inspiration_speed() -> void:
Settings.inspiration_list_speed -= 1
if Settings.inspiration_list_speed < 1:
Settings.inspiration_list_speed = 1
inspiration_speed_label.text = str(Settings.inspiration_list_speed)
func increase_inspiration_speed() -> void:
Settings.inspiration_list_speed += 1
inspiration_speed_label.text = str(Settings.inspiration_list_speed )
func lower_hide_for_seconds() -> void:
Settings.hide_for_seconds -= 1.0
if Settings.hide_for_seconds < 0:
Settings.hide_for_seconds = 0
hide_for_seconds_label.text = str(Settings.hide_for_seconds)
func increase_hide_for_seconds() -> void:
Settings.hide_for_seconds += 1.0
if Settings.hide_for_seconds > 10.0:
Settings.hide_for_seconds = 10.0
hide_for_seconds_label.text = str(Settings.hide_for_seconds)
func select_server(new_server: int) -> void:
GameLog.debug("select_server")
Settings.default_path = select_server_button.get_item_text(new_server)
Settings.selected_server = new_server
GameLog.debug("Settings.default_path: " + Settings.default_path)
GameLog.debug("Settings.selected_server: " + str(Settings.selected_server))
GameLog.debug("new_server: " + str(new_server))
GameLog.debug("select_server_button.get_item_text(new_server): ",
select_server_button.get_item_text(new_server))
server_changed.emit()
_use_server(new_server)
## Picks [param new_server] in the list and uses it.
func set_selected_server(new_server: int) -> void:
GameLog.debug("set_selected_server")
select_server_button.select(new_server)
Settings.default_path = select_server_button.get_item_text(new_server)
_use_server(new_server)
func _use_server(new_server: int) -> void:
Settings.default_path = Settings.servers[new_server]
Settings.selected_server = new_server
GameLog.debug("Settings.default_path: " + Settings.default_path)
GameLog.debug("Settings.selected_server: " + str(Settings.selected_server))
GameLog.debug("new_server: " + str(new_server))
GameLog.debug("select_server_button.get_item_text(new_server): ",
select_server_button.get_item_text(new_server))
server_changed.emit()
# Winning a game turns statistics off, so the toggles are refreshed every time this opens
func _on_visibility_changed() -> void:
if visible:
_update_fullscreen_button()
show_cache_size()
random_mode_button.select(Settings.random_mode)
add_to_database_button.set_pressed_no_signal(Settings.add_to_stats)
else:
folder_error_label.visible = false
characters_error_label.visible = false
_fit_to_rows()
func _on_play_order_selected(order: int) -> void:
if Settings.is_offline():
Settings.music_folder.set_play_order(order as MusicFolder.PlayOrder)
server_changed.emit()
# Shows where songs come from, and turns off what needs the server while offline
func _show_music_source() -> void:
var folder: MusicFolder = Settings.music_folder
folder_error_label.visible = false
use_server_button.visible = folder != null
if folder == null:
folder_label.text = "None, using the server"
folder_label.tooltip_text = ""
else:
folder_label.text = folder.display_name()
folder_label.tooltip_text = folder.path
play_order_row.visible = folder != null && folder.kind == MusicFolder.Kind.PREPARED_QUIZ
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
_fit_to_rows()
# Grows or shrinks the panel to fit the rows shown, keeping it centred
func _fit_to_rows() -> void:
var height: float = rows.get_combined_minimum_size().y + PADDING.y * 2
settings_panel.size.y = height
settings_panel.position.y = floorf(_panel_middle_y - height / 2)
rows.position = PADDING
rows.size = Vector2(settings_panel.size.x - PADDING.x * 2, height - PADDING.y * 2)
func _show_in_file_manager(dir: String) -> void:
var error: Error = OS.shell_show_in_file_manager(dir)
if error != OK:
# Not every platform has a file manager to show a folder in; open it instead
error = OS.shell_open(dir)
if error != OK:
GameLog.warning("Could not open %s: %s" % [dir, error_string(error)])
func _update_fullscreen_button() -> void:
fullscreen_button.set_pressed_no_signal(Settings.fullscreen)
+60 -46
View File
@@ -1,65 +1,79 @@
class_name ShortcutsPopup
extends BasePopup
## Lists the keyboard shortcuts. Opened with Alt+K or the Shortcuts button.
## Lists the keyboard shortcuts, grouped by what they do. Opened with Alt+K or the
## Shortcuts button.
var shortcuts_dic: Dictionary = {
"Alt + S": "Search",
"Alt + A": "Add Players",
"Alt + Z": "Reset",
"Alt + X": "Play/Pause",
"Alt + C": "Next Song",
"Alt + V": "Show Answer",
"Alt + L": "Show Debug Log",
"Alt + K": "Show shortcuts",
"Alt + Enter": "Fullscreen",
"Alt + UP": "Volume up",
"Alt + DOWN": "Volume down",
"Alt + LEFT": "Jump back in song",
"Alt + Ctrl + LEFT": "Restart song",
"Alt + RIGHT": "Jump forward in song",
"Alt + 1 - 9": "Give player point",
"Alt + Ctrl + 1 - 9": "Take point from player",
}
## Each group's heading and its shortcuts, as [code][keys, what they do][/code].
## [code]tests/integration/test_shortcuts_popup.gd[/code] checks that every button
## shortcut in the game is here with the right keys.
const SHORTCUTS: Array = [
["Players and points", [
["Alt + 1–9", "Give a player a point"],
["Alt + Ctrl + 1–9", "Take a point from a player"],
["Alt + A", "Add players"],
]],
["Songs", [
["Alt + C", "Next song"],
["Alt + V", "Show answer"],
["Alt + X", "Play or pause"],
["Alt + Z", "Restart song"],
["Alt + Ctrl + Left", "Restart song"],
["Alt + Left", "Jump back 5 seconds"],
["Alt + Right", "Jump forward 5 seconds"],
["Alt + Up", "Volume up"],
["Alt + Down", "Volume down"],
]],
["Windows", [
["Alt + S", "Search"],
["Alt + K", "Shortcuts"],
["Alt + L", "Log"],
["Alt + Enter", "Fullscreen"],
]],
]
## Space above each group but the first.
const GROUP_GAP: Vector2 = Vector2(0, 8)
@onready var shortcuts_vbox: VBoxContainer = %shortcutsVBox
@onready var shortcuts_panel: Panel = %ShortcutsPanel
func _ready() -> void:
shortcuts_dic.sort()
Settings.delete_children(shortcuts_vbox)
for group: Array in SHORTCUTS:
if shortcuts_vbox.get_child_count() > 0:
var gap: Control = Control.new()
gap.custom_minimum_size = GROUP_GAP
shortcuts_vbox.add_child(gap)
var heading: Label = Label.new()
heading.theme_type_variation = &"HighlightLarge"
heading.text = group[0]
shortcuts_vbox.add_child(heading)
var row: int = 0
for shortcut: Array in group[1]:
shortcuts_vbox.add_child(_make_row(shortcut[0], shortcut[1], row))
row += 1
var title: Label = Label.new()
title.theme_type_variation = &"TextLarge"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.text = "Shortcuts"
shortcuts_vbox.add_child(title)
var count: int = 0
for shortcut: String in shortcuts_dic.keys():
## Every key combination in the list, e.g. "Alt + C".
static func listed_keys() -> Array[String]:
var keys: Array[String] = []
for group: Array in SHORTCUTS:
for shortcut: Array in group[1]:
keys.append(shortcut[0])
return keys
func _make_row(keys: String, action: String, row: int) -> PanelContainer:
var panel: PanelContainer = PanelContainer.new()
if row % 2 == 0:
panel.theme_type_variation = &"ShortcutRowDark"
var hbox: HBoxContainer = HBoxContainer.new()
hbox.alignment = BoxContainer.ALIGNMENT_CENTER
for text: String in [keys, action]:
var label: Label = Label.new()
label.theme_type_variation = &"TextLarge"
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.text = shortcut
label.autowrap_mode = TextServer.AUTOWRAP_WORD
label.text = text
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var label2: Label = Label.new()
label2.theme_type_variation = &"TextLarge"
label2.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label2.text = shortcuts_dic[shortcut]
label2.autowrap_mode = TextServer.AUTOWRAP_WORD
label2.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if count % 2 == 0:
panel.theme_type_variation = &"ShortcutRowDark"
hbox.add_child(label)
hbox.add_child(label2)
panel.add_child(hbox)
shortcuts_vbox.add_child(panel)
count += 1
return panel
+288 -10
View File
@@ -1,27 +1,305 @@
class_name SoundtrackStatsPopup
extends BasePopup
## Shows a soundtrack's play statistics and every song in it, most played first.
## Opened by clicking a card in the statistics view.
## Shows a soundtrack's cover, play statistics and alternative names, and every song in
## 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 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 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 show_soundtrack(soundtrack: SoundtrackWithSongs) -> void:
title_label.text = soundtrack.soundtrack_name
var summary: PackedStringArray = [SoundtrackCard.played_text(soundtrack.soundtrack_played)]
var last_played: String = SoundtrackCard.last_played_text(soundtrack.soundtrack_last_played)
func _ready() -> void:
edit_names_button.pressed.connect(edit_names)
add_name_button.pressed.connect(add_name)
new_name_field.text_submitted.connect(func(_text: String) -> void: add_name())
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():
summary.append(last_played)
summary.append("%d %s" % [soundtrack.songs.size(),
"song" if soundtrack.songs.size() == 1 else "songs"])
summary.append("%d %s" % [shown.songs.size(),
"song" if shown.songs.size() == 1 else "songs"])
summary_label.text = " · ".join(summary)
_show_names()
cover_placeholder.text = SoundtrackCard.initials(shown.soundtrack_name)
_load_cover()
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():
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
&"TextLarge"))
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 progress_hbox: HBoxContainer = %ProgressHBoxContainer
@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_separator: VSeparator = %SoundtracksAddedSeparator
@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:
GameLog.debug("start_sync")
visible = true
open()
if has_synced:
# Already synced, just show the results
show_sync_result()
@@ -77,13 +77,24 @@ func _on_sync_timeout() -> void:
GameLog.debug("data_received: ", data_received)
if data_received != null:
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 ""
else:
on_sync_finished(data_received)
_finish_progress(data_received)
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:
has_synced = true
reset_sync_result()
@@ -218,6 +229,7 @@ func show_sync_result() -> void:
func show_sync_progress() -> void:
sync_title_label.text = "Sync in Progress"
progress_hbox.visible = true
sync_progress_bar.wait()
no_changes_label.visible = false
sync_hbox.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
+124 -21
View File
@@ -2,7 +2,12 @@ class_name WinnerPopup
extends BasePopup
## Shows who won, with their character, where the other players placed, and how the game
## went: a graph of everyone's score after each song and a few facts about it.
##
## The winners' pictures bounce in, confetti falls over the panel and the graph draws its
## lines from left to right. See [Anim].
## How many pieces of confetti fly when someone wins.
const CONFETTI_AMOUNT: int = 160
## Size of the coloured square in front of each name in the legend.
const LEGEND_SQUARE_SIZE: Vector2 = Vector2(14, 14)
@@ -22,43 +27,141 @@ func _ready() -> void:
func show_winner(winner: PlayerObject) -> void:
visible = true
winner_label.text = winner.player_name + " won with " + str(winner.player_score) + \
" points!!"
winner_picture.texture = winner.character
# The "no character" placeholder is pixel art, which blurs when scaled up smoothly
if winner.character.resource_path == PlayerObject.NO_CHARACTER_PATH:
winner_picture.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
else:
winner_picture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
var winners: Array[PlayerObject] = [winner]
show_winners(winners)
## Shows [param winners], who share the top score, with everyone else's placing below.
## Tied players get the same placing. With no players, it says the quiz is over.
func show_winners(winners: Array[PlayerObject]) -> void:
open()
winner_label.text = winners_text(winners)
_show_pictures(winners)
# Clear previous player list entries
for child: Node in players_list.get_children():
child.queue_free()
# Sort players by score (descending) and add to list
var sorted_players: Array = Settings.player_array.duplicate()
var sorted_players: Array[PlayerObject] = Settings.player_array.duplicate()
sorted_players.sort_custom(
func(a: PlayerObject, b: PlayerObject) -> bool:
return a.player_score > b.player_score
)
var placing: int = 2
for player: PlayerObject in sorted_players:
if player == winner:
continue # Skip the winner, already shown above
if player in winners:
continue
var label: Label = Label.new()
label.text = str(placing) + ". " + player.player_name + " - " + \
str(player.player_score) + " points"
label.text = "%d. %s - %s" % [placing(player), player.player_name,
points_text(player.player_score)]
players_list.add_child(label)
placing += 1
show_history(ScoreHistory.from_playlist(Settings.player_array))
_celebrate()
## The headline for [param winners], e.g. "Crono won with 5 points!!" or "Crono and
## Marle won with 5 points!!".
static func winners_text(winners: Array[PlayerObject]) -> String:
if winners.is_empty():
return "The quiz is over!"
var names: PackedStringArray = []
for winner: PlayerObject in winners:
names.append(winner.player_name)
var joined: String = names[-1]
if names.size() > 1:
joined = ", ".join(names.slice(0, -1)) + " and " + names[-1]
return "%s won with %s!!" % [joined, points_text(winners[0].player_score)]
## [param points] in words, e.g. "1 point" or "5 points".
static func points_text(points: int) -> String:
return "%d point%s" % [points, "" if points == 1 else "s"]
## Where [param player] placed: 1 plus how many players have more points, so tied
## players share a placing.
static func placing(player: PlayerObject) -> int:
var place: int = 1
for other: PlayerObject in Settings.player_array:
if other.player_score > player.player_score:
place += 1
return place
# The winners' pictures bounce in, confetti flies and the graph draws itself
func _celebrate() -> void:
for picture: Node in winner_picture.get_parent().get_children():
if picture is TextureRect && (picture as TextureRect).visible:
Anim.bounce_in(picture)
score_graph.draw_lines_in()
if Anim.enabled():
_throw_confetti()
# A burst of confetti from the top of the panel, in the players' graph colours
func _throw_confetti() -> void:
var old: Node = winner_panel.get_node_or_null("Confetti")
if old != null:
old.free()
var confetti: CPUParticles2D = CPUParticles2D.new()
confetti.name = "Confetti"
confetti.position = Vector2(winner_panel.size.x / 2, 0)
confetti.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
confetti.emission_rect_extents = Vector2(winner_panel.size.x / 2, 4)
confetti.amount = CONFETTI_AMOUNT
confetti.lifetime = 2.6
confetti.one_shot = true
confetti.explosiveness = 0.85
confetti.direction = Vector2.DOWN
confetti.spread = 35.0
confetti.gravity = Vector2(0, 260)
confetti.initial_velocity_min = 80.0
confetti.initial_velocity_max = 260.0
confetti.angular_velocity_min = -360.0
confetti.angular_velocity_max = 360.0
confetti.scale_amount_min = 5.0
confetti.scale_amount_max = 9.0
var colours: Gradient = Gradient.new()
colours.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_CONSTANT
colours.offsets = PackedFloat32Array()
colours.colors = PackedColorArray()
for i: int in 7:
colours.add_point(i / 7.0, score_graph.player_color(i))
confetti.color_initial_ramp = colours
confetti.finished.connect(confetti.queue_free)
winner_panel.add_child(confetti)
confetti.emitting = true
# One picture per winner, next to each other
func _show_pictures(winners: Array[PlayerObject]) -> void:
var header: Node = winner_picture.get_parent()
for extra: Node in header.get_children():
if extra != winner_picture && extra is TextureRect:
header.remove_child(extra)
extra.queue_free()
winner_picture.visible = !winners.is_empty()
for index: int in winners.size():
var picture: TextureRect = winner_picture
if index > 0:
picture = winner_picture.duplicate()
header.add_child(picture)
header.move_child(picture, winner_picture.get_index() + index)
_show_character(picture, winners[index])
func _show_character(picture: TextureRect, player: PlayerObject) -> void:
picture.texture = player.character
# The "no character" placeholder is pixel art, which blurs when scaled up smoothly
if player.character.resource_path == PlayerObject.NO_CHARACTER_PATH:
picture.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
else:
picture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
## Shows how the game went: the score graph, its legend and the summary boxes.
func show_history(history: ScoreHistory) -> void:
score_graph.show_history(history, Settings.winning_score)
# A prepared quiz has no winning score to draw
var winning_score: int = 0 if Settings.is_prepared_quiz() else Settings.winning_score
score_graph.show_history(history, winning_score)
streak_label.text = history.longest_streak_text()
standstill_label.text = history.longest_standstill_text()
shared_label.text = history.most_shared_text()
+2 -1
View File
@@ -13,6 +13,7 @@ script = ExtResource("part_script")
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=707750459]
layout_mode = 2
follow_focus = true
horizontal_scroll_mode = 0
script = ExtResource("7_dj026")
[node name="MusicList" type="VBoxContainer" parent="ScrollContainer" unique_id=373398568]
@@ -20,4 +21,4 @@ unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 10
theme_override_constants/separation = 4
+38 -3
View File
@@ -2,22 +2,57 @@
[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
offset_right = 1088.0
offset_bottom = 320.0
theme_override_constants/separation = 32
alignment = 1
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)
layout_mode = 2
theme_type_variation = &"TitleLarge"
horizontal_alignment = 1
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)
layout_mode = 2
theme_type_variation = &"TitleMedium"
horizontal_alignment = 1
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://b2kj6m8qpsgb1" path="res://icons/person_remove_light.svg" id="3_j3uxe"]
[node name="PlayerRow" type="Control"]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 3
anchors_preset = 0
offset_bottom = 40.0
[node name="PlayerRow" type="PanelContainer"]
custom_minimum_size = Vector2(0, 40)
size_flags_horizontal = 3
theme_type_variation = &"ListRowEven"
script = ExtResource("1_if4kc")
[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
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
custom_minimum_size = Vector2(130, 40)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 1
text = ": 0"
vertical_alignment = 1
clip_text = true
text_overrun_behavior = 3
size_flags_vertical = 4
theme_type_variation = &"PlayerCharacter"
[node name="Panel3" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(16, 2.08165e-12)
layout_mode = 2
theme_type_variation = &"DarkSpacer"
[node name="Character" type="TextureButton" parent="HBoxContainer"]
[node name="Character" type="TextureButton" parent="HBoxContainer/CharacterFrame"]
unique_name_in_owner = true
custom_minimum_size = Vector2(80, 40)
custom_minimum_size = Vector2(64, 32)
layout_mode = 2
tooltip_text = "Change character"
focus_mode = 0
action_mode = 0
texture_normal = ExtResource("2_hpj3s")
ignore_texture_size = true
stretch_mode = 5
[node name="Panel2" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(30, 40)
[node name="Name" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
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"]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 40)
custom_minimum_size = Vector2(40, 36)
layout_mode = 2
size_flags_vertical = 4
focus_mode = 0
action_mode = 0
theme_type_variation = &"StepButton"
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"]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 40)
custom_minimum_size = Vector2(40, 36)
layout_mode = 2
size_flags_vertical = 4
focus_mode = 0
action_mode = 0
theme_type_variation = &"StepButton"
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"]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 40)
visible = false
custom_minimum_size = Vector2(36, 36)
layout_mode = 2
size_flags_vertical = 4
tooltip_text = "Remove player"
focus_mode = 0
action_mode = 0
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="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"]
device = -1
@@ -17,6 +16,7 @@ events = [SubResource("InputEventKey_03bm3")]
layout_mode = 3
offset_right = 641.0
offset_bottom = 401.0
theme_type_variation = &"PlayerList"
script = ExtResource("part_script")
[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=270084211]
@@ -26,55 +26,63 @@ layout_mode = 2
custom_minimum_size = Vector2(2.08165e-12, 40)
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]
custom_minimum_size = Vector2(2.08165e-12, 40)
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 1
theme_type_variation = &"TextLarge"
text = "Players"
horizontal_alignment = 1
vertical_alignment = 1
[node name="AddPlayersButton" type="TextureButton" parent="VBoxContainer/HBoxContainer" unique_id=634410148]
unique_name_in_owner = true
custom_minimum_size = Vector2(2.08165e-12, 40)
custom_minimum_size = Vector2(36, 36)
layout_mode = 2
size_flags_horizontal = 8
size_flags_vertical = 4
tooltip_text = "Add or remove players (Alt+A)"
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_jafqj")
texture_normal = ExtResource("5_31tjv")
ignore_texture_size = true
stretch_mode = 5
[node name="AddPlayerContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=1371948739]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(2.08165e-12, 40)
custom_minimum_size = Vector2(0, 36)
layout_mode = 2
theme_override_constants/separation = 8
alignment = 1
[node name="Panel" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=1569854056]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"DarkSpacer"
[node name="PlayerNameField" type="TextEdit" parent="VBoxContainer/AddPlayerContainer" unique_id=332535327]
[node name="PlayerNameField" type="LineEdit" parent="VBoxContainer/AddPlayerContainer" unique_id=332535327]
unique_name_in_owner = true
custom_minimum_size = Vector2(2.08165e-12, 40)
custom_minimum_size = Vector2(220, 0)
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Player name"
script = ExtResource("7_qsdfy")
max_length = 10
[node name="AddPlayerButton" type="Button" parent="VBoxContainer/AddPlayerContainer" unique_id=245001313]
unique_name_in_owner = true
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
focus_mode = 0
text = "Add"
[node name="Panel2" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=60215448]
custom_minimum_size = Vector2(2.08165e-12, 40)
[node name="AddPlayerHint" type="Label" parent="VBoxContainer/AddPlayerContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"DarkSpacer"
theme_type_variation = &"HighlightMedium"
text = "Up to 7 players"
vertical_alignment = 1
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer" unique_id=1000000001]
layout_mode = 2
@@ -84,5 +92,6 @@ horizontal_scroll_mode = 0
[node name="PlayersListVBoxContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer" unique_id=1000000002]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 2
size_flags_horizontal = 3
size_flags_vertical = 15
+40 -60
View File
@@ -3,7 +3,8 @@
[ext_resource type="Script" path="res://quiz/quiz.gd" id="quiz_script"]
[ext_resource type="PackedScene" uid="uid://ds15cgsf8vpvc" path="res://player/MusicPlayer.tscn" id="5_emn36"]
[ext_resource type="PackedScene" uid="uid://cdy4kvemwaiom" path="res://popups/CharacterSelectPopup.tscn" id="6_dhrvg"]
[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://popups/SettingsPopup.tscn" id="11_k62u5"]
[ext_resource type="PackedScene" path="res://quiz/QuizSettingsCard.tscn" id="scene_QuizSettingsCard"]
[ext_resource type="Texture2D" path="res://icons/settings_light_icon.svg" id="settings_icon"]
[ext_resource type="PackedScene" uid="uid://cxlsmxnrp0agh" path="res://popups/WinnerPopup.tscn" id="17_winner_window"]
[ext_resource type="PackedScene" path="res://quiz/MusicListPanel.tscn" id="scene_MusicListPanel"]
[ext_resource type="PackedScene" path="res://quiz/PlayersPanel.tscn" id="scene_PlayersPanel"]
@@ -23,113 +24,92 @@ grow_vertical = 2
mouse_filter = 2
script = ExtResource("quiz_script")
[node name="Open" type="Button" parent="." unique_id=165052329]
visible = false
layout_mode = 0
offset_left = 1296.0
offset_top = 66.0
offset_right = 1346.0
offset_bottom = 97.0
text = "Open"
[node name="FileDialog" type="FileDialog" parent="." unique_id=485771559]
title = "Open a Directory"
initial_position = 2
size = Vector2i(616, 400)
ok_button_text = "Select Current Folder"
file_mode = 2
access = 2
show_hidden_files = true
[node name="MusicListPanel" parent="." unique_id=2011292375 instance=ExtResource("scene_MusicListPanel")]
layout_mode = 0
offset_left = 64.0
offset_top = 384.0
offset_right = 1152.0
offset_bottom = 896.0
offset_bottom = 894.0
[node name="InspirationList" parent="." unique_id=498283761 instance=ExtResource("scene_InspirationList")]
layout_mode = 0
offset_left = 1216.0
offset_top = 128.0
offset_right = 1857.0
offset_right = 1856.0
offset_bottom = 512.0
[node name="NowPlaying" parent="." unique_id=42468498 instance=ExtResource("scene_NowPlaying")]
layout_mode = 0
offset_left = 64.0
offset_top = 64.0
offset_top = 56.0
offset_right = 1152.0
offset_bottom = 384.0
[node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")]
layout_mode = 0
offset_left = 64.0
offset_top = 992.0
offset_top = 970.0
offset_right = 1856.0
offset_bottom = 1063.0
offset_bottom = 1012.0
[node name="PlayersPanel" parent="." unique_id=1708375945 instance=ExtResource("scene_PlayersPanel")]
layout_mode = 0
offset_left = 1216.0
offset_top = 576.0
offset_right = 1857.0
offset_bottom = 977.0
offset_top = 544.0
offset_right = 1856.0
offset_bottom = 954.0
[node name="QuizToolbar" parent="." unique_id=1873301941 instance=ExtResource("scene_QuizToolbar")]
layout_mode = 0
offset_left = 1360.0
offset_left = 1216.0
offset_top = 64.0
offset_right = 1855.0
offset_bottom = 95.0
offset_right = 1856.0
offset_bottom = 100.0
[node name="SongControls" parent="." unique_id=1640212773 instance=ExtResource("scene_SongControls")]
layout_mode = 0
offset_left = 65.0
offset_top = 944.0
offset_right = 368.0
offset_bottom = 975.0
[node name="SettingsPopup" parent="." unique_id=1323246978 instance=ExtResource("11_k62u5")]
visible = false
layout_mode = 1
offset_left = 720.0
offset_top = 153.0
offset_right = 720.0
offset_bottom = 153.0
offset_left = 64.0
offset_top = 918.0
offset_right = 704.0
offset_bottom = 954.0
[node name="CharacterSelectPopup" parent="." unique_id=1679856322 instance=ExtResource("6_dhrvg")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 405.0
offset_top = 165.0
offset_right = 1557.0
offset_bottom = 805.0
offset_left = 170.0
offset_top = 180.0
offset_right = 1750.0
offset_bottom = 900.0
[node name="WinnerPopup" parent="." unique_id=999999999 instance=ExtResource("17_winner_window")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 160.0
offset_top = 100.0
offset_top = 80.0
offset_right = 160.0
offset_bottom = 100.0
offset_bottom = 80.0
[node name="DebugLabel" type="Label" parent="." unique_id=1959221257]
[node name="QuizSettingsButton" type="Button" parent="." unique_id=1323246979]
layout_mode = 0
offset_left = 64.0
offset_top = 1028.0
offset_right = 224.0
offset_bottom = 1064.0
focus_mode = 0
action_mode = 0
text = "Quiz settings"
icon = ExtResource("settings_icon")
expand_icon = true
[node name="QuizSettingsCard" parent="." unique_id=1323246980 instance=ExtResource("scene_QuizSettingsCard")]
visible = false
layout_mode = 0
offset_left = 974.0
offset_top = 904.0
offset_right = 1014.0
offset_bottom = 927.0
scale = Vector2(3, 3)
text = "DEBUG"
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
offset_left = 64.0
offset_top = 962.0
offset_right = 64.0
offset_bottom = 962.0
[node name="SongLoader" type="Node" parent="." unique_id=1520867349]
script = ExtResource("song_loader_script")
[connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"]
+219
View File
@@ -0,0 +1,219 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://quiz/quiz_settings_card.gd" id="1_card_script"]
[node name="QuizSettingsCard" type="Control" node_paths=PackedStringArray("panel")]
layout_mode = 3
anchors_preset = 0
mouse_filter = 2
script = ExtResource("1_card_script")
panel = NodePath("CardPanel")
[node name="CardPanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_top = -470.0
offset_right = 440.0
theme_type_variation = &"PopupBackground"
[node name="VBox" type="VBoxContainer" parent="CardPanel"]
unique_name_in_owner = true
layout_mode = 0
offset_left = 16.0
offset_top = 12.0
offset_right = 424.0
[node name="TitleLabel" type="Label" parent="CardPanel/VBox"]
layout_mode = 2
theme_type_variation = &"Title"
text = "Quiz settings"
horizontal_alignment = 1
[node name="AutoRepeatCheckButton" type="CheckButton" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Auto repeat"
[node name="StopAfterCurrentCheckButton" type="CheckButton" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Stop after current"
[node name="HideNextTrackCheckButton" type="CheckButton" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
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"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Hide the beginning of songs"
[node name="HideForSecondsRow" type="HBoxContainer" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
[node name="Title" type="Label" parent="CardPanel/VBox/HideForSecondsRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Seconds to hide"
[node name="HideForSecondsLabel" type="Label" parent="CardPanel/VBox/HideForSecondsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "5"
horizontal_alignment = 1
[node name="LowerHideForSecondsButton" type="Button" parent="CardPanel/VBox/HideForSecondsRow"]
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 = "-1"
[node name="IncreaseHideForSecondsButton" type="Button" parent="CardPanel/VBox/HideForSecondsRow"]
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 = "+1"
[node name="HideLengthCheckButton" type="CheckButton" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Hide song length"
[node name="HideTicksCheckButton" type="CheckButton" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Hide ticks in the playbar"
[node name="WinningScoreRow" type="HBoxContainer" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
[node name="Title" type="Label" parent="CardPanel/VBox/WinningScoreRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Winning score"
[node name="ScoreLabel" type="Label" parent="CardPanel/VBox/WinningScoreRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "20"
horizontal_alignment = 1
[node name="LowerButton" type="Button" parent="CardPanel/VBox/WinningScoreRow"]
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 = "-1"
[node name="IncreaseButton" type="Button" parent="CardPanel/VBox/WinningScoreRow"]
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 = "+1"
[node name="InspirationRow" type="HBoxContainer" parent="CardPanel/VBox"]
layout_mode = 2
[node name="Title" type="Label" parent="CardPanel/VBox/InspirationRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Inspiration list speed"
[node name="InspirationListSpeedLabel" type="Label" parent="CardPanel/VBox/InspirationRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "1"
horizontal_alignment = 1
[node name="LowerInspirationSpeedButton" type="Button" parent="CardPanel/VBox/InspirationRow"]
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 = "-1"
[node name="IncreaseInspirationSpeedButton" type="Button" parent="CardPanel/VBox/InspirationRow"]
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 = "+1"
+21 -23
View File
@@ -2,44 +2,42 @@
[ext_resource type="Script" path="res://quiz/quiz_toolbar.gd" id="part_script"]
[node name="QuizToolbar" type="Control" unique_id=1873301941]
[sub_resource type="InputEventKey" id="InputEventKey_search"]
device = -1
alt_pressed = true
keycode = 83
[sub_resource type="Shortcut" id="Shortcut_search"]
events = [SubResource("InputEventKey_search")]
[node name="QuizToolbar" type="HBoxContainer" unique_id=1873301941]
layout_mode = 3
offset_right = 495.0
offset_bottom = 31.0
mouse_filter = 2
theme_override_constants/separation = 12
alignment = 2
script = ExtResource("part_script")
[node name="SearchButton" type="Button" parent="." unique_id=1782453581]
layout_mode = 2
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_search")
text = "Search"
[node name="ResetPlaylistButton" type="Button" parent="." unique_id=1754155620]
layout_mode = 0
offset_right = 111.0
offset_bottom = 31.0
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Reset playlist"
[node name="ResetPointsButton" type="Button" parent="." unique_id=942880776]
layout_mode = 0
offset_left = 128.0
offset_right = 239.0
offset_bottom = 31.0
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Reset points"
[node name="SettingsButton" type="Button" parent="." unique_id=1193400323]
layout_mode = 0
offset_left = 256.0
offset_right = 367.0
offset_bottom = 31.0
focus_mode = 0
action_mode = 0
text = "Settings"
[node name="SoundTestButton" type="Button" parent="." unique_id=1332971879]
layout_mode = 0
offset_left = 384.0
offset_right = 495.0
offset_bottom = 31.0
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Sound test"
+6 -11
View File
@@ -20,29 +20,24 @@ unicode = 231
[sub_resource type="Shortcut" id="Shortcut_d6fml"]
events = [SubResource("InputEventKey_7dnqw")]
[node name="SongControls" type="Control" unique_id=1640212773]
[node name="SongControls" type="HBoxContainer" unique_id=1640212773]
layout_mode = 3
offset_right = 303.0
offset_bottom = 31.0
mouse_filter = 2
theme_override_constants/separation = 12
alignment = 0
script = ExtResource("part_script")
[node name="ShowAnswerButton" type="Button" parent="." unique_id=415388039]
layout_mode = 0
offset_right = 111.0
offset_bottom = 31.0
layout_mode = 2
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_a7fvb")
text = "Show answer"
[node name="NextButton" type="Button" parent="." unique_id=1744703488]
layout_mode = 0
offset_left = 128.0
offset_right = 303.0
offset_bottom = 31.0
layout_mode = 2
focus_mode = 0
disabled = true
action_mode = 0
shortcut = SubResource("Shortcut_d6fml")
text = "Randomize new track"
text = "Next song"
+9 -43
View File
@@ -1,63 +1,29 @@
[gd_scene format=3 uid="uid://1jbtmhbqolqe"]
[ext_resource type="Texture2D" uid="uid://c8r7ykphss4c2" path="res://characters/Link.png" id="1_ll28s"]
[ext_resource type="Script" uid="uid://cjo13wixip8mq" path="res://quiz/song_row.gd" id="1_lspki"]
[node name="SongRow" type="PanelContainer"]
custom_minimum_size = Vector2(1000, 0)
offset_right = 1088.0
offset_bottom = 59.0
theme_type_variation = &"SongRow"
script = ExtResource("1_lspki")
[node name="HBoxContainer" type="HBoxContainer" parent="."]
layout_mode = 2
size_flags_vertical = 0
mouse_filter = 2
theme_override_constants/separation = 12
[node name="SongListLabel" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
texture_filter = 2
custom_minimum_size = Vector2(865, 28)
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 1
theme_type_variation = &"SongRowLabel"
text = "Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv) - 'Crystal Cave -arrange-'' from FINAL FANTASY III"
autowrap_mode = 2
vertical_alignment = 1
autowrap_mode = 3
[node name="Panel" type="Panel" parent="HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 0
[node name="SongListPlayers" type="HBoxContainer" parent="HBoxContainer"]
[node name="SongListPlayers" type="VBoxContainer" parent="HBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
size_flags_vertical = 0
[node name="TextureRect" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
custom_minimum_size = Vector2(50, 25)
layout_mode = 2
texture = ExtResource("1_ll28s")
expand_mode = 1
stretch_mode = 5
[node name="TextureRect2" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
custom_minimum_size = Vector2(50, 25)
layout_mode = 2
texture = ExtResource("1_ll28s")
expand_mode = 1
stretch_mode = 6
[node name="TextureRect3" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
custom_minimum_size = Vector2(50, 25)
layout_mode = 2
texture = ExtResource("1_ll28s")
expand_mode = 1
[node name="TextureRect4" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
custom_minimum_size = Vector2(50, 25)
layout_mode = 2
texture = ExtResource("1_ll28s")
expand_mode = 1
stretch_mode = 6
size_flags_vertical = 4
mouse_filter = 2
theme_type_variation = &"SongRowPictureLines"
+7 -3
View File
@@ -48,9 +48,13 @@ func _process(delta: float) -> void:
scroll_bar.value = 0
## Fetches every soundtrack from the server and shows them.
func load_from_server() -> void:
GameLog.debug("load_from_server")
## Shows every soundtrack: the music folder's soundtracks when one is picked, otherwise
## the server's.
func load_soundtracks() -> void:
GameLog.debug("load_soundtracks")
if Settings.is_offline():
show_soundtracks(Settings.music_folder.soundtrack_names())
return
var populate_list: Callable = func populate_list(array: Variant) -> void:
if typeof(array) == TYPE_ARRAY:
show_soundtracks(array)
+23 -2
View File
@@ -1,12 +1,18 @@
class_name MusicListPanel
extends PanelContainer
## The list of songs in the playlist, one row per song.
## The list of songs in the playlist, one row per song. A song's row flashes when the
## song starts playing and when it is answered.
## Emitted when the row of song number [param song_no] is clicked.
signal song_clicked(song_no: int)
var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
# Whether each song was playing and answered when the list was last shown, so a row
# can flash when its song starts or is answered
var _last_state: Dictionary = {}
# When each flash ends, in msec, so a flash goes on when the list is shown again
var _flash_ends: Dictionary = {}
@onready var _rows: VBoxContainer = %MusicList
@@ -14,11 +20,26 @@ var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
## Shows the songs in the playlist again, so it shows their latest state.
func refresh() -> void:
Settings.delete_children(_rows)
for s: SongObject in Playlist.get_playlist():
var songs: Array[SongObject] = Playlist.get_playlist()
var state: Dictionary = {}
for i: int in songs.size():
var s: SongObject = songs[i]
var new_song_list_object: SongRow = _song_list_object.instantiate()
new_song_list_object.song_object = s
new_song_list_object.index = i
new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number))
_rows.add_child(new_song_list_object)
state[s] = [s.is_playing, s.is_answered]
# Only songs that were listed before flash, not a whole list loaded at once
if _last_state.has(s) && _last_state[s] != state[s] \
&& (s.is_playing || s.is_answered):
_flash_ends[s] = Time.get_ticks_msec() + int(Anim.SLOW * 1000)
var left: float = (_flash_ends.get(s, 0) - Time.get_ticks_msec()) / 1000.0
if left > 0:
Anim.flash(new_song_list_object, get_theme_color(&"flash", &"Palette"), left)
else:
_flash_ends.erase(s)
_last_state = state
func _on_row_input(event: InputEvent, song_no: int) -> void:
+101 -6
View File
@@ -1,10 +1,105 @@
class_name NowPlaying
extends VBoxContainer
## The soundtrack and song that are playing, in large text at the top of the quiz.
extends HBoxContainer
## 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
## question marks until the answer is shown.
func show_song(soundtrack_title: String, song_title: String) -> void:
$SoundtrackLabel.text = soundtrack_title
$SongLabel.text = song_title
## question marks until the answer is shown, and only then gives the
## [param soundtrack_uuid] whose cover is shown.
func show_song(soundtrack_title: String, song_title: String,
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
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 player_removed
@@ -12,6 +17,9 @@ var player_object: PlayerObject
var list_index: int
@onready var player_name_field: Label = %Name
@onready var score_label: Label = %Score
@onready var match_point_tag: Label = %MatchPointTag
@onready var character_frame: PanelContainer = %CharacterFrame
@onready var add_button: Button = %AddPoint
@onready var minus_button: Button = %RemovePoint
@onready var character: TextureButton = %Character
@@ -25,12 +33,23 @@ func _ready() -> void:
minus_button.set_shortcut(create_button_shortcut(list_index, true))
character.pressed.connect(change_character)
remove_player.pressed.connect(func() -> void: player_removed.emit())
player_object.player_point_given.connect(_on_score_changed)
player_object.player_point_taken.connect(_on_score_changed)
player_object.first_point_triggered.connect(_on_first_point)
character.mouse_entered.connect(_set_character_hovered.bind(true))
character.mouse_exited.connect(_set_character_hovered.bind(false))
# Rows take turns being lighter and darker, like the other lists
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()
set_player_character()
func _process(delta: float) -> void:
remove_player.visible = Settings.edit_players
## Shows the button that removes the player while the player list is being edited.
func set_editing(editing: bool) -> void:
remove_player.visible = editing
func create_button_shortcut(index: int, ctrl_pressed: bool) -> Shortcut:
@@ -57,22 +76,50 @@ func change_character() -> void:
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")
player_object.character = character_texture
player_object.set_character(character_texture, character_name)
set_player_character()
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
func update_score() -> void:
if OS.has_feature("debug"):
player_name_field.text = "%d %s: %d" % [list_index + 1, player_object.player_name,
player_object.player_score]
else:
player_name_field.text = player_object.player_name + ": " + str(player_object.player_score)
score_label.text = str(player_object.player_score)
_show_match_point(is_match_point())
## Whether the player is one point from winning. A prepared quiz has no winning score.
func is_match_point() -> bool:
return !Settings.is_prepared_quiz() && Settings.winning_score > 1 \
&& player_object.player_score == Settings.winning_score - 1
func _on_score_changed() -> void:
Anim.pop(score_label)
func _on_first_point() -> void:
Anim.flash(self, get_theme_color(&"first_point", &"Palette"), Anim.SLOW * 2)
Anim.pop(score_label, 1.8, Anim.SLOW)
# The glow and the tag show while the player is on match point
func _show_match_point(on: bool) -> void:
var glow: Node = get_node_or_null("Pulse")
if on == match_point_tag.visible && on == (glow != null):
return
match_point_tag.visible = on
if glow != null:
glow.queue_free()
glow.name = "PulseEnding"
if on:
Anim.pulse(self, get_theme_color(&"match_point", &"Palette"))
Anim.pop(match_point_tag, 1.4, Anim.SLOW)
func _set_character_hovered(hovered: bool) -> void:
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_player_container: HBoxContainer = %AddPlayerContainer
@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
func _ready() -> void:
add_players_button.pressed.connect(add_players)
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.
func add_players() -> void:
GameLog.debug("add_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()
## Adds a player with the name in the name field, unless it is empty or there are
## already as many players as there can be.
## Adds a player with the name in the name field. Blank names, names another player
## already has, and players beyond [constant Settings.MAX_NUMBER_OF_PLAYERS] are not
## added.
func add_player() -> void:
GameLog.debug("add_player")
if new_player_name_field.text != "" \
&& Settings.player_array.size() < Settings.MAX_NUMBER_OF_PLAYERS:
var new_player_object: PlayerObject = PlayerObject.new(new_player_name_field.text)
var player_name: String = new_player_name_field.text.strip_edges()
if !can_add(player_name):
return
var new_player_object: PlayerObject = PlayerObject.new(player_name)
new_player_name_field.text = ""
Settings.player_array.append(new_player_object)
player_added.emit(new_player_object)
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
## points were reset.
func reload() -> void:
@@ -71,6 +88,16 @@ func reload() -> void:
players_list_container.add_child(new_player)
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:
+184 -81
View File
@@ -1,21 +1,26 @@
class_name Quiz
extends Control
## The quiz: the song that is playing, the playlist, the players and their points, and
## the popups opened from here (settings, character select, winner and log).
## the popups opened from here (the quiz settings card, character select and winner).
##
## 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
## 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
## reloaded from it.
signal server_changed
## Emitted when Search is pressed. The Search popup is in the main window.
signal search_pressed
## Emitted with a line for the game log, e.g. when a player gets a point.
signal event_logged(text: String)
var songs: Array = []
var current_player_row: PlayerRow
## True while statistics are off because a player won, until points are reset.
@@ -24,37 +29,36 @@ var stats_paused_by_win: bool = false
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
var popup_backdrop: PopupBackdrop
@onready var open_button: Button = $Open
@onready var file_dialog: FileDialog = $FileDialog
# 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 now_playing: NowPlaying = $NowPlaying
@onready var players_panel: PlayersPanel = $PlayersPanel
@onready var toolbar: QuizToolbar = $QuizToolbar
@onready var settings_popup: SettingsPopup = $SettingsPopup
@onready var quiz_settings_card: QuizSettingsCard = $QuizSettingsCard
@onready var quiz_settings_button: Button = $QuizSettingsButton
@onready var winner_popup: WinnerPopup = %WinnerPopup
@onready var song_controls: SongControls = $SongControls
@onready var song_loader: SongLoader = $SongLoader
@onready var music_list_panel: MusicListPanel = $MusicListPanel
@onready var character_select_popup: CharacterSelectPopup = %CharacterSelectPopup
@onready var music_player: MusicPlayer = $MusicPlayer
@onready var debug_label: Label = $DebugLabel
func _ready() -> void:
popup_backdrop = PopupBackdrop.add_to(self, _popups())
if OS.has_feature("debug"):
GameLog.debug("is_debug")
debug_label.visible = true
Settings.is_debug = true
settings_popup.set_winning_score(5)
settings_popup.set_selected_server(4)
song_controls.next_pressed.connect(play_next_song)
song_controls.show_answer_pressed.connect(show_answer_pressed)
song_controls.show_winner_pressed.connect(show_final_winners)
toolbar.sound_test_pressed.connect(music_player.get_sound_test_song)
toolbar.settings_pressed.connect(show_settings)
toolbar.search_pressed.connect(search_pressed.emit)
quiz_settings_button.pressed.connect(quiz_settings_card.toggle)
toolbar.reset_playlist_pressed.connect(reset_playlist)
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)
character_select_popup.character_selected.connect(_on_new_character_selected)
@@ -64,14 +68,15 @@ func _ready() -> void:
players_panel.change_character_requested.connect(_on_change_character_requested)
music_player.play_next_song.connect(play_next_song)
music_player.update_song_list.connect(update_song_list)
settings_popup.server_changed.connect(_on_server_changed)
settings_popup.visibility_changed.connect(_on_settings_popup_visibility_changed)
character_select_popup.source_changed.connect(_on_character_source_changed)
song_loader.saved_songs_dropped.connect(_on_saved_songs_dropped)
open_button.pressed.connect(open)
inspiration_list.load_from_server()
song_loader.load_song_list()
var saved: QuizSave = QuizSave.read()
_continue_saved_quiz(saved)
inspiration_list.load_soundtracks()
song_loader.load_song_list(saved)
character_select_popup.load_characters()
_load_saved_characters(saved)
func _input(event: InputEvent) -> void:
@@ -110,13 +115,14 @@ func on_sync_finished() -> void:
## Reloads the soundtracks, playlist and characters from the server.
func refresh_from_server() -> void:
GameLog.debug("refresh_from_server")
inspiration_list.load_from_server()
inspiration_list.load_soundtracks()
reset_playlist()
character_select_popup.load_characters()
func _on_server_changed() -> void:
GameLog.debug("_on_server_changed")
## Reloads everything from the server picked in the settings and starts a new game.
func on_server_changed() -> void:
GameLog.debug("on_server_changed")
refresh_from_server()
reset_points()
server_changed.emit()
@@ -130,49 +136,165 @@ func show_answer_pressed() -> void:
func play_next_song() -> void:
GameLog.debug("play_next_song ", Playlist.latest_played_song)
# Every song of a prepared quiz has played, e.g. when songs play on by themselves
if Settings.is_prepared_quiz() && Playlist.first_song_played \
&& Playlist.get_preloaded_songs() == 0:
song_controls.show_winner_button()
return
if Playlist.first_song_played:
Playlist.set_latest_played_song()
Playlist.first_song_played = true
music_player.play_song_object(Playlist.latest_played_song)
if !(Playlist.get_preloaded_songs() > 0):
song_controls.set_next_enabled(false)
# The song counts as played once it starts, which can take a moment if another song
# is stopping, so the next song is picked after that
await music_player.play_song_object(Playlist.latest_played_song)
song_loader.fetch_song_if_idle()
if !(Playlist.get_preloaded_songs() > 0):
if Settings.is_prepared_quiz():
song_controls.show_winner_button()
else:
song_controls.set_next_enabled(false)
# The quiz settings card is left out: it does not dim the quiz
func _popups() -> Array[BasePopup]:
return [settings_popup, character_select_popup, winner_popup]
# The cache size can be changed in the settings. Waiting until the settings are closed
# avoids starting downloads while the user is still clicking the number up and down.
func _on_settings_popup_visibility_changed() -> void:
if !settings_popup.visible:
song_loader.fill_cache()
return [character_select_popup, winner_popup]
func _on_song_added() -> void:
song_controls.set_next_enabled(true)
if Settings.is_debug:
update_song_list()
save_soon()
func update_song_list() -> void:
GameLog.debug("update_song_list")
if Playlist.currently_playing_song >= 0:
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()
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:
GameLog.debug("Clicked Song Object: ", Playlist.song_object_array[song_no].to_string())
if !Settings.character_select_open:
if Playlist.get_song(song_no) == null:
if Settings.is_offline():
GameLog.warning("The song's file is gone: ",
Playlist.song_object_array[song_no].song_path)
return
GameLog.debug("has no song, need to download")
var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void:
var sound: AudioStream = AudioStreamMP3.new()
sound.data = body
Playlist.add_song_to_song_object(song_no, sound)
Playlist.add_song_to_song_object(song_no, body)
music_player.play_song_object(song_no)
MusicServerClient.fetch_song(song_no, song_downloaded)
else:
@@ -185,7 +307,10 @@ func reset_playlist() -> void:
var playlist_reset_done: Callable = func playlist_reset_done() -> void:
Playlist.reset_playlist()
song_loader.load_song_list()
if Settings.is_offline():
Settings.music_folder.restart()
playlist_reset_done.call()
return
MusicServerClient.reset_playlist(playlist_reset_done)
@@ -202,11 +327,7 @@ func reset_points() -> void:
if stats_paused_by_win:
Settings.add_to_stats = true
stats_paused_by_win = false
func show_settings() -> void:
GameLog.debug("show_settings")
settings_popup.open()
save_soon()
# Each new player's points reach the music player, the log and the winner popup.
@@ -217,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.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))
save_soon()
func _on_point_given(player_object: PlayerObject) -> void:
@@ -240,6 +362,20 @@ func _on_point_taken(player_object: PlayerObject) -> void:
update_song_list()
## Shows the players with the most points as the winners, at the end of a prepared quiz.
func show_final_winners() -> void:
GameLog.debug("show_final_winners")
var top_score: int = 0
for player: PlayerObject in Settings.player_array:
top_score = maxi(top_score, player.player_score)
var winners: Array[PlayerObject] = []
for player: PlayerObject in Settings.player_array:
if player.player_score == top_score:
winners.append(player)
winner_popup.show_winners(winners)
music_player.play_song(preload("res://sounds/winning.mp3"))
func _on_player_won(player_object: PlayerObject) -> void:
GameLog.debug("_on_player_won")
winner_popup.show_winner(player_object)
@@ -248,6 +384,7 @@ func _on_player_won(player_object: PlayerObject) -> void:
if Settings.add_to_stats:
Settings.add_to_stats = false
stats_paused_by_win = true
save_soon()
func _on_player_removed(player_object: PlayerObject) -> void:
@@ -262,41 +399,7 @@ func _on_change_character_requested(new_player: PlayerRow, player_name: String)
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")
current_player_row.new_character_selected(character_texture)
###Local
var local_path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3'
func open() -> void:
file_dialog.popup()
func _on_file_dialog_dir_selected(path: String) -> void:
GameLog.debug(path)
dir_contents(path)
func dir_contents(path: String) -> void:
var dir: DirAccess = DirAccess.open(path)
if dir:
dir.list_dir_begin()
var file_name: String = dir.get_next()
songs.clear()
var soundtracks: Array = []
while file_name != "":
if dir.current_is_dir():
#GameLog.debug("Found directory: " + file_name)
soundtracks.append(file_name)
else:
#GameLog.debug("Found file: " + file_name)
if file_name.ends_with(".mp3"):
songs.append(path + "/" + file_name)
file_name = dir.get_next()
inspiration_list.show_soundtracks(soundtracks)
else:
GameLog.warning("An error occurred when trying to access the path.")
current_player_row.new_character_selected(character_texture, character_name)
save_soon()
+171
View File
@@ -0,0 +1,171 @@
class_name QuizSettingsCard
extends BasePopup
## The quiz settings, in a small card that opens above the Quiz settings button by the
## music player: how songs are played and hidden, the winning score and the inspiration
## list. It does not dim the quiz, so it can be changed during a song.
##
## Every change is saved at once with [method Settings.save_settings].
##
## The card's bottom edge is where the card is placed, just above the player bar, and
## it grows upward to fit its rows.
##
## The application settings, such as the server, are in [SettingsPopup].
## The most seconds of a song's beginning that can be hidden.
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.
const PADDING: Vector2 = Vector2(16, 12)
@onready var card_panel: Panel = %CardPanel
@onready var rows: VBoxContainer = %VBox
@onready var auto_repeat_button: CheckButton = %AutoRepeatCheckButton
@onready var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton
@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_for_seconds_row: HBoxContainer = %HideForSecondsRow
@onready var hide_for_seconds_label: Label = %HideForSecondsLabel
@onready var lower_hide_for_seconds_button: Button = %LowerHideForSecondsButton
@onready var increase_hide_for_seconds_button: Button = %IncreaseHideForSecondsButton
@onready var hide_length_button: CheckButton = %HideLengthCheckButton
@onready var hide_ticks_button: CheckButton = %HideTicksCheckButton
@onready var winning_score_row: HBoxContainer = %WinningScoreRow
@onready var score_label: Label = %ScoreLabel
@onready var lower_winning_score_button: Button = %LowerButton
@onready var increase_winning_score_button: Button = %IncreaseButton
@onready var inspiration_speed_label: Label = %InspirationListSpeedLabel
@onready var lower_inspiration_speed_button: Button = %LowerInspirationSpeedButton
@onready var increase_inspiration_speed_button: Button = %IncreaseInspirationSpeedButton
func _ready() -> void:
auto_repeat_button.toggled.connect(_save_toggle.bind(&"auto_repeat_song"))
stop_after_current_button.toggled.connect(_save_toggle.bind(&"stop_after_current"))
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_length_button.toggled.connect(_save_toggle.bind(&"hide_length"))
hide_ticks_button.toggled.connect(_save_toggle.bind(&"hide_ticks"))
lower_winning_score_button.pressed.connect(lower_winning_score)
increase_winning_score_button.pressed.connect(increase_winning_score)
lower_inspiration_speed_button.pressed.connect(lower_inspiration_speed)
increase_inspiration_speed_button.pressed.connect(increase_inspiration_speed)
lower_hide_for_seconds_button.pressed.connect(lower_hide_for_seconds)
increase_hide_for_seconds_button.pressed.connect(increase_hide_for_seconds)
visibility_changed.connect(_on_visibility_changed)
show_settings()
## Shows the current settings. This runs every time the card opens, in case a setting
## was changed somewhere else.
func show_settings() -> void:
auto_repeat_button.set_pressed_no_signal(Settings.auto_repeat_song)
stop_after_current_button.set_pressed_no_signal(Settings.stop_after_current)
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_length_button.set_pressed_no_signal(Settings.hide_length)
hide_ticks_button.set_pressed_no_signal(Settings.hide_ticks)
hide_for_seconds_row.visible = Settings.hide_beginning
hide_for_seconds_label.text = "%d" % Settings.hide_for_seconds
score_label.text = str(Settings.winning_score)
# A prepared quiz is won by having the most points when every song has played
winning_score_row.visible = !Settings.is_prepared_quiz()
inspiration_speed_label.text = str(Settings.inspiration_list_speed)
_fit_to_rows()
func lower_winning_score() -> void:
set_winning_score(Settings.winning_score - 1)
func increase_winning_score() -> void:
set_winning_score(Settings.winning_score + 1)
## Sets the winning score, never below 1.
func set_winning_score(new_score: int) -> void:
Settings.winning_score = maxi(new_score, 1)
score_label.text = str(Settings.winning_score)
Settings.save_settings()
func lower_inspiration_speed() -> void:
Settings.inspiration_list_speed = maxi(Settings.inspiration_list_speed - 1, 1)
inspiration_speed_label.text = str(Settings.inspiration_list_speed)
Settings.save_settings()
func increase_inspiration_speed() -> void:
Settings.inspiration_list_speed += 1
inspiration_speed_label.text = str(Settings.inspiration_list_speed)
Settings.save_settings()
func lower_hide_for_seconds() -> void:
Settings.hide_for_seconds = maxf(Settings.hide_for_seconds - 1.0, 0.0)
hide_for_seconds_label.text = "%d" % Settings.hide_for_seconds
Settings.save_settings()
func increase_hide_for_seconds() -> void:
Settings.hide_for_seconds = minf(Settings.hide_for_seconds + 1.0, MAX_HIDE_SECONDS)
hide_for_seconds_label.text = "%d" % Settings.hide_for_seconds
Settings.save_settings()
# Sets the on/off quiz setting named [param setting] and saves the quiz settings
func _save_toggle(on: bool, setting: StringName) -> void:
Settings.set(setting, on)
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:
_save_toggle(on, &"hide_beginning")
hide_for_seconds_row.visible = on
_fit_to_rows()
# Grows or shrinks the card upward, keeping its bottom edge in place
func _fit_to_rows() -> void:
var height: float = rows.get_combined_minimum_size().y + PADDING.y * 2
card_panel.position.y = -height
card_panel.size.y = height
rows.position = PADDING
rows.size = Vector2(card_panel.size.x - PADDING.x * 2, height - PADDING.y * 2)
func _on_visibility_changed() -> void:
if visible:
show_settings()
+1
View File
@@ -0,0 +1 @@
uid://dsap67d5cdg34
+4 -4
View File
@@ -1,11 +1,11 @@
class_name QuizToolbar
extends Control
## The row of quiz buttons under the top bar: reset playlist, reset points, settings
## and sound test.
## The row of quiz buttons under the top bar: search, reset playlist, reset points and
## sound test.
signal search_pressed
signal reset_playlist_pressed
signal reset_points_pressed
signal settings_pressed
signal sound_test_pressed
@onready var reset_playlist_button: Button = $ResetPlaylistButton
@@ -13,9 +13,9 @@ signal sound_test_pressed
func _ready() -> void:
$SearchButton.pressed.connect(search_pressed.emit)
reset_playlist_button.pressed.connect(reset_playlist_pressed.emit)
$ResetPointsButton.pressed.connect(reset_points_pressed.emit)
$SettingsButton.pressed.connect(settings_pressed.emit)
sound_test_button.pressed.connect(sound_test_pressed.emit)
+29 -1
View File
@@ -4,6 +4,15 @@ extends Control
signal show_answer_pressed
signal next_pressed
## Emitted when Next is pressed at the end of a prepared quiz, where it reads
## "Show winner".
signal show_winner_pressed
const NEXT_TEXT: String = "Next song"
const SHOW_WINNER_TEXT: String = "Show winner"
## True while Next reads "Show winner", after the last song of a prepared quiz.
var shows_winner: bool = false
@onready var show_answer_button: Button = $ShowAnswerButton
@onready var next_button: Button = $NextButton
@@ -11,17 +20,36 @@ signal next_pressed
func _ready() -> void:
show_answer_button.pressed.connect(show_answer_pressed.emit)
next_button.pressed.connect(next_pressed.emit)
next_button.pressed.connect(_on_next_button_pressed)
## Enables Next while there is a downloaded song to play next.
func set_next_enabled(enabled: bool) -> void:
_set_shows_winner(false)
next_button.disabled = !enabled
## Turns Next into "Show winner", for when the last song of a prepared quiz has played.
func show_winner_button() -> void:
_set_shows_winner(true)
next_button.disabled = false
## Disables both buttons, e.g. while the server syncs the song list. Unlocking enables
## Show answer again; Next comes back when the next song has been downloaded.
func set_locked(locked: bool) -> void:
show_answer_button.disabled = locked
if locked:
next_button.disabled = true
func _set_shows_winner(on: bool) -> void:
shows_winner = on
next_button.text = SHOW_WINNER_TEXT if on else NEXT_TEXT
func _on_next_button_pressed() -> void:
if shows_winner:
show_winner_pressed.emit()
else:
next_pressed.emit()
+95 -10
View File
@@ -1,7 +1,8 @@
class_name SongLoader
extends Node
## Downloads songs from the server into the Playlist ahead of time, so the next song
## can start at once.
## can start at once. With a music folder (see [method Settings.is_offline]) the songs are
## picked from the folder instead.
##
## How many songs it keeps ready is the cache size in the settings
## ([code]Playlist.number_of_tracks_to_preload[/code]).
@@ -14,35 +15,87 @@ signal song_list_loaded
## Emitted when a new song has been downloaded and added to the Playlist.
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.
var retry_seconds: float = 5.0
## 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.
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")
if Settings.is_offline():
_start_folder_playlist(saved)
return
var song_list_fetched: Callable = func song_list_fetched(songs: Array[SongInfo]) -> void:
if Settings.is_offline():
return
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()
new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = info.soundtrack
new_song_object.song_title = info.song
new_song_object.soundtrack_uuid = info.soundtrack_uuid
new_song_object.is_answered = true
new_song_object.has_played = true
Playlist.add_to_playlist(new_song_object)
if continued == 0:
Playlist.update_last_played_song()
# Songs already on the server were played before, so the game starts after them
Playlist.game_start_song = Playlist.song_object_array.size()
song_list_loaded.emit()
fetch_song_if_idle()
MusicServerClient.fetch_song_list(song_list_fetched, _retry_later.bind(load_song_list))
# Songs downloaded ahead before a restart already fill some of the cache
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.
func fetch_song() -> void:
GameLog.debug("fetch_song")
if Settings.is_offline():
_add_folder_songs()
return
Playlist.is_song_downloading = true
_download_random_song(SongObject.new())
@@ -65,11 +118,12 @@ func fill_cache() -> void:
func _download_random_song(new_song_object: SongObject) -> void:
var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void:
GameLog.debug("song_downloaded")
var sound: AudioStream = AudioStreamMP3.new()
sound.data = body
new_song_object.song = sound
# A music folder was picked while the song was downloading
if Settings.is_offline():
return
Playlist.store_song(new_song_object, body)
_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)))
@@ -81,9 +135,12 @@ func _add_song_to_queue(new_song_object: SongObject) -> void:
func _fetch_song_info(new_song_object: SongObject) -> void:
var song_info_fetched: Callable = func song_info_fetched(info: SongInfo) -> void:
GameLog.debug("song_info_fetched")
if Settings.is_offline():
return
new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = info.soundtrack
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())
Playlist.add_to_playlist(new_song_object)
if Playlist.should_preload_more_songs():
@@ -97,4 +154,32 @@ func _fetch_song_info(new_song_object: SongObject) -> void:
func _retry_later(step: Callable) -> void:
GameLog.warning("Song download failed, retrying in %s seconds" % retry_seconds)
get_tree().create_timer(retry_seconds).timeout.connect(step)
get_tree().create_timer(retry_seconds).timeout.connect(func() -> void:
if !Settings.is_offline():
step.call())
# A music folder has no playlist on a server, so the game starts with an empty one, or
# with the saved quiz's songs
func _start_folder_playlist(saved: QuizSave = null) -> void:
Playlist.clear_song_list()
Playlist.update_last_played_song()
Playlist.game_start_song = 0
if saved != null && !saved.songs.is_empty():
_continue_songs(saved)
Playlist.is_song_downloading = false
song_list_loaded.emit()
fill_cache()
# Picks songs from the music folder until enough are ready. A prepared quiz runs out
# when every song is in the playlist.
func _add_folder_songs() -> void:
Playlist.is_song_downloading = false
while Playlist.should_preload_more_songs():
var song_object: SongObject = Settings.music_folder.next_song()
if song_object == null:
return
song_object.song_number = Playlist.song_object_array.size()
Playlist.add_to_playlist(song_object)
song_added.emit()
+80 -12
View File
@@ -1,37 +1,105 @@
class_name SongRow
extends PanelContainer
## One song in the music list: its number, soundtrack and title, and a marker for
## One song in the music list: its number, soundtrack and title, and the character of
## each player who got a point for it.
##
## The pictures are on the right and the text takes the room they leave, wrapping only
## when it does not fit on one line. With many pictures they go on more lines, as even as
## possible and at most [constant MAX_PICTURES_PER_LINE] on each, so the text keeps most
## of the row. Every line ends at the row's right edge. The row is as tall as the taller
## of the text and the pictures.
## The most pictures on one line before they go on another.
const MAX_PICTURES_PER_LINE: int = 4
## The size of each picture. Characters are twice as wide as they are tall.
const PICTURE_SIZE: Vector2 = Vector2(56, 28)
var song_object: SongObject
## The row's place in the list, for the alternating background.
var index: int = 0
var _hovered: bool = false
@onready var song_list_label: Label = %SongListLabel
@onready var song_list_players: HBoxContainer = %SongListPlayers
## The lines of pictures, top to bottom, each an HBoxContainer.
@onready var song_list_players: VBoxContainer = %SongListPlayers
func _ready() -> void:
mouse_entered.connect(_set_hovered.bind(true))
mouse_exited.connect(_set_hovered.bind(false))
set_label()
update_players()
## The pictures shown, line by line.
func pictures() -> Array[TextureRect]:
var shown: Array[TextureRect] = []
for line: Node in song_list_players.get_children():
shown.append_array(line.get_children())
return shown
## The number of pictures on a full line for [param pictures] pictures: as few lines as
## [constant MAX_PICTURES_PER_LINE] allows, with the pictures spread evenly over them,
## e.g. 3 and 2 for five pictures instead of 4 and 1.
static func picture_columns(pictures: int) -> int:
if pictures <= 0:
return 1
var lines: int = ceili(float(pictures) / MAX_PICTURES_PER_LINE)
return ceili(float(pictures) / lines)
func set_label() -> void:
if song_object != null:
var size_to_change: int = song_object.players_given_point.size() * 50
song_list_label.custom_minimum_size = Vector2(1000 - size_to_change, 28)
if song_object.is_playing:
song_list_label.theme_type_variation = &"SongRowLabelPlaying"
song_list_label.theme_type_variation = &"SongRowLabelPlaying" \
if song_object.is_playing else &"SongRowLabel"
song_list_label.text = song_object.get_song_info()
_update_style()
func update_players() -> void:
Settings.delete_children(song_list_players)
var scorers: Array[PlayerObject] = []
if song_object != null:
# Show players in the same order as the player list
for player: PlayerObject in Settings.player_array:
if song_object.players_given_point.has(player.id):
var texture: TextureRect = TextureRect.new()
texture.texture = player.character
texture.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
texture.custom_minimum_size = Vector2(50, 25)
song_list_players.add_child(texture)
scorers.append(player)
var per_line: int = picture_columns(scorers.size())
var line: HBoxContainer = null
for i: int in scorers.size():
if i % per_line == 0:
line = HBoxContainer.new()
line.theme_type_variation = &"SongRowPictureLine"
line.alignment = BoxContainer.ALIGNMENT_END
line.mouse_filter = Control.MOUSE_FILTER_IGNORE
song_list_players.add_child(line)
line.add_child(_make_picture(scorers[i]))
song_list_players.visible = !scorers.is_empty()
func _make_picture(player: PlayerObject) -> TextureRect:
var picture: TextureRect = TextureRect.new()
picture.texture = player.character
picture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
picture.custom_minimum_size = PICTURE_SIZE
picture.tooltip_text = player.player_name
# Clicks on a picture still click the row
picture.mouse_filter = Control.MOUSE_FILTER_PASS
return picture
func _set_hovered(hovered: bool) -> void:
_hovered = hovered
_update_style()
func _update_style() -> void:
if _hovered:
theme_type_variation = &"SongRowHover"
elif song_object != null && song_object.is_playing:
theme_type_variation = &"SongRowPlaying"
else:
theme_type_variation = &"SongRowOdd" if index % 2 != 0 else &"SongRow"
+5
View File
@@ -32,6 +32,11 @@ find_godot() {
godot_bin="$(find_godot)"
# Keeps the tests away from the real quiz settings file, see Settings.CONFIG_DIR_ENV
MUSICPLAYER_CONFIG_DIR="$(mktemp -d)"
export MUSICPLAYER_CONFIG_DIR
trap 'rm -rf "$MUSICPLAYER_CONFIG_DIR"' EXIT
paths=("$@")
if [[ ${#paths[@]} -eq 0 ]]; then
paths=(res://tests/unit res://tests/integration)
+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 CharacterDetails
extends RefCounted
## The [code]CharacterDetails[/code] schema of the MusicServer API.
## When the image was last changed, in Unix seconds
var modified: int
## File name of the character image
var name: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> CharacterDetails:
var result: CharacterDetails = CharacterDetails.new()
result.modified = ApiJson.get_int(data, "modified", 0)
result.name = ApiJson.get_string(data, "name", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"modified": modified,
"name": name,
}
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more