Author SHA1 Message Date
SansanandClaude Opus 5.5 8263781abc Merge develop into feature/50-upload-soundtrack
Build / test (pull_request) Successful in 2m3s
Build / build (pull_request) Skipped
The API spec is now formatted like before (2-space indents, sorted keys):
develop's spec with the upload paths and types added, so the diff to
develop is only the upload API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:29:18 +02:00
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 c86dacb890 Add a soundtrack by picking or dropping a folder (#50)
Build / test (pull_request) Successful in 2m3s
Build / build (pull_request) Skipped
"Add soundtrack…" in the statistics toolbar, or a folder dropped anywhere on
the window, opens a popup that lists the folder's songs and uploads them to
the server, which adds the soundtrack at once (Sansan/MusicServer#33).

- The name comes from the folder and can be changed; characters the server
  refuses are replaced.
- Only MP3s are uploaded, as the server takes no other; other song files
  are listed greyed out as left out. Hidden files are skipped.
- Disc folders are flattened with the disc's number first ("1-01 Title.mp3"),
  as the server wants one folder. With one disc folder, names stay as
  they are.
- Songs are sent one at a time with a progress bar, reading one file at a
  time. A failed song is sent again twice, then the upload is cancelled
  and the server's reason shown. Closing the popup cancels it too.
- Not offered while the server syncs, offline, or without MP3s.
- Afterwards search, the inspiration list and the statistics reload.

The API types come from the server's spec (Upload, UploadFile,
UploadRequest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:43:59 +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
Sansan 13fdb3f68c Show the server's version history in the About popup
Build / test (push) Successful in 38s
Build / build (push) Successful in 1m13s
The About popup is twice as wide and shows the player's and the server's
version history side by side, each in its own scrolling column. The
server's comes from GET /api/v1/version/history every time the popup
opens. The description, which is about both, is centred above the
columns, and each history starts right under its version.
2026-10-07 14:32:31 +02:00
Sansan e1e05f3904 Show a graph of the game on the Winner popup
Build / test (push) Successful in 38s
Build / build (push) Successful in 1m12s
The Winner popup now shows every player's score after each song as a
step line, with the winning score marked, and three facts under it: the
longest streak, the longest standstill and the song most players scored
on. The data comes from the points each song already records, through
the new ScoreHistory. The popup grows to 1600x920 with the graph across
the full width, so a 60-song game with five players stays readable.

Reset points now starts a new game: it clears the points on the songs
and the graph starts after the song that was playing. Songs already on
the server at startup count as an earlier game.

The screenshot tour has a long five-player game, and only the Winner
popup differs from before.
2026-10-07 10:40:46 +02:00
Sansan b40b160fde Show Japanese text and close the gap in the top bar
Build / test (push) Successful in 35s
Build / build (push) Successful in 1m12s
Godot's built-in font has no Japanese, so kanji in song names showed as
boxes. The theme now sets Godot's own Open Sans SemiBold as its default
font, with Noto Sans JP Medium as fallback. The Noto file's line metrics
are set to Open Sans's, so line heights stay the same and every other
screen is pixel-identical; fonts/README.md explains how to redo it.

Search and Sync move right to where the Statistics button was.
2026-10-06 23:57:37 +02:00
Sansan a21b6e9b22 Add a Statistics tab with a card for every soundtrack
Build / test (push) Successful in 40s
Build / build (push) Successful in 1m16s
Part of #41. The top bar has Quiz and Statistics tabs instead of the
Statistics button. The Statistics tab replaces the quiz below the top bar
(the song keeps playing) and shows every soundtrack as a card with its
plays, last played date and most and least played songs, loaded from the
new GET /api/v1/statistics/soundtracks. The cards can be searched (with
the Search popup's matching, now in SoundtrackSearch), filtered on played
or never played, and sorted A-Z, Z-A, most or least played. Clicking a
card opens a popup with all its songs.

Screenshots of the other states only differ in the top bar, and the
layout baseline is taken again with the new statistics states.
2026-10-06 23:33:00 +02:00
Sansan f7619baf34 Use /api/v1 with token authentication and typed responses
Closes #38, #39 and #40; adds the client side of #41.

- Every request goes to /api/v1. MusicServerClient gets a token with
  Settings.api_password the first time one is needed, reuses it, and on a
  401 gets a new one and sends the request once more. Switching server gets
  a new token too. Callers never see the token.
- Sync is started with POST, as MusicServer 5.0 requires.
- The client has every endpoint in the server's OpenAPI spec, including
  statistics, soundtracks and songs by UUID, version, downloads and tokens.
- tools/generate_api_types.gd generates a typed class per schema in
  server/openapi.json into server/generated/. Song info, statistics and
  versions are passed to callers as these classes. A list the server sends
  as null is an empty list.
- FakeHttpTransport hands out tokens and answers 401 without one, so every
  test goes through the token flow. New tests cover the token handling,
  every endpoint and each generated class.
2026-10-06 22:45:28 +02:00
290 changed files with 85556 additions and 4208 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.
+23 -3
View File
@@ -26,8 +26,8 @@ pass.
| Folder | Contents |
|---|---|
| `tests/unit/` | Logic without scenes: playlist, songs, player scoring, time formatting, search, the server client |
| `tests/integration/` | Real scenes (`MainWindow.tscn`, `SyncPopup.tscn`) run against a fake server, including the point keybinds |
| `tests/unit/` | Logic without scenes: playlist, songs, player scoring, time formatting, search, the server client and the API classes |
| `tests/integration/` | Real scenes (`MainWindow.tscn`, `SyncPopup.tscn`) run against a fake server, including the point keybinds and the Statistics tab |
| `tests/fakes/` | `FakeHttpTransport`, `AutoloadSnapshot` and the `MainWindowTestSuite` base class |
| `tests/data/` | Files the fake server sends, e.g. a short MP3 |
@@ -48,7 +48,27 @@ assert_int(server.count("/music/rand")).is_equal(1)
server.uninstall()
```
Paths without a response get a 404, so the code under test sees a failed request.
Paths are relative to the API (`/music/list` is `<server>/api/v1/music/list`). Paths
without a response get a 404, so the code under test sees a failed request.
Like the real server, the fake hands out tokens on `POST /token` and answers 401 to
requests without a valid one, so the client's token handling runs in every test. Token
requests are counted in `server.token_requests`, not in `requested_paths`. Use
`server.expire_tokens()` to make the client renew its token, `server.hold("/token")` to
keep it waiting for one, and `server.respond_json("/token", {...}, 401)` for a refused
password.
## API classes
The classes in `server/generated/` (`SongInfo`, `StatisticsSummary`, ...) are generated
from the server's OpenAPI spec, committed as `server/openapi.json`. To update them, copy
a new spec there (`just openapi` in MusicServer, or `<server>/openapi.json`) and run:
```bash
godot --headless --path . -s tools/generate_api_types.gd
```
`tests/unit/test_api_types.gd` parses a sample response with each class.
## Writing tests
+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
+147
View File
@@ -0,0 +1,147 @@
class_name SoundtrackSearch
extends RefCounted
## Finds soundtrack names matching a search text. Matches ignore case, spaces and
## 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 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. [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:
_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
## case-insensitive matches, then matches ignoring spaces and special characters,
## then matches with the letters in order but not next to each other.
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 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),
]
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:
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \
.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:
var regex: RegEx = RegEx.new()
var regex_text: String = ".*"
for letter: String in search_term:
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
if "\\^$.|?*+()[]{}".contains(letter):
regex_text += "\\"
regex_text += letter + ".*"
regex.compile(regex_text)
_regex_cache[search_term] = regex
return _regex_cache[search_term]
+1
View File
@@ -0,0 +1 @@
uid://df8ox6xddobu2
+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
+56 -16
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,23 +74,27 @@ 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:
player_score += 1
player_point_given.emit()
var first_point: bool = player_score == 1 && is_first_point
if player_score == 1:
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:
winner_triggered.emit()
elif player_score == Settings.winning_score - 1:
match_point_triggered.emit()
elif first_point:
first_point_triggered.emit()
else:
point_given_sound.emit()
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
if player_score == 1:
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 has_winning_score && player_score == Settings.winning_score:
winner_triggered.emit()
elif has_winning_score && player_score == Settings.winning_score - 1:
match_point_triggered.emit()
elif first_point:
first_point_triggered.emit()
else:
point_given_sound.emit()
func minus_point() -> void:
+83 -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
@@ -11,6 +18,17 @@ var first_song_downloaded: bool = false
var first_song_played: bool = false
var number_of_tracks_to_preload: int = 1
var is_song_downloading: bool = false
## The first song of the current game. Songs before it belong to earlier games, which
## 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:
@@ -45,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)
@@ -68,7 +86,9 @@ func get_playlist() -> Array[SongObject]:
func reset_playlist() -> void:
currently_playing_song = -1
latest_played_song = 0
game_start_song = 0
clear_song_list()
clear_song_files()
func clear_song_list() -> void:
@@ -88,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()
@@ -100,18 +124,73 @@ func remove_point(player_id: String) -> void:
song_object_array[currently_playing_song].remove_point(player_id)
## Removes every player's points from every song, for a new game.
func clear_points() -> void:
for song_object: SongObject in song_object_array:
song_object.players_given_point.clear()
## Removes a player's points from every song.
func remove_player_from_list(player_id: String) -> void:
for song_object: SongObject in song_object_array:
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
+121
View File
@@ -0,0 +1,121 @@
class_name ScoreHistory
extends RefCounted
## Who got a point on which song of the current game, for the graph and the summary on
## the Winner popup.
##
## Songs are counted from 1 at the start of the game, which is the start of the playlist
## or the first song after the last Reset points.
## The players, in the order of [code]Settings.player_array[/code].
var players: Array[PlayerObject] = []
## For each song of the game, in the order they were played, the ids of the players who
## got a point on it.
var songs: Array[Array] = []
## The current game, from [code]Playlist.game_start_song[/code] up to the last song that
## has played. Songs downloaded ahead but not played yet are left out, and so are points
## of players who are no longer in [param game_players].
static func from_playlist(game_players: Array[PlayerObject]) -> ScoreHistory:
var song_scorers: Array[Array] = []
# Not just the song playing now: an older song may have been picked in the music list
var last_song: int = mini(Playlist.currently_playing_song,
Playlist.song_object_array.size() - 1)
for song_no: int in Playlist.song_object_array.size():
if Playlist.song_object_array[song_no].has_played:
last_song = maxi(last_song, song_no)
for song_no: int in range(Playlist.game_start_song, last_song + 1):
var song: SongObject = Playlist.song_object_array[song_no]
if song.has_played || song_no == Playlist.currently_playing_song:
song_scorers.append(song.players_given_point)
return from_songs(game_players, song_scorers)
## A history of [param song_scorers], the ids of the players who scored on each song.
static func from_songs(game_players: Array[PlayerObject], song_scorers: Array) -> ScoreHistory:
var history: ScoreHistory = ScoreHistory.new()
history.players = game_players.duplicate()
var ids: Array[String] = []
for player: PlayerObject in game_players:
ids.append(player.id)
for scorers: Array in song_scorers:
history.songs.append(scorers.filter(func(id: String) -> bool: return ids.has(id)))
return history
## [param player_id]'s score before the first song and after each song.
func scores_after_songs(player_id: String) -> Array[int]:
var scores: Array[int] = [0]
for scorers: Array in songs:
scores.append(scores.back() + (1 if scorers.has(player_id) else 0))
return scores
## [param player_id]'s score after the last song.
func final_score(player_id: String) -> int:
return scores_after_songs(player_id).back()
## The player with the most songs in a row with a point, e.g.
## "Crono, 5 songs in a row (14–18)". The earliest streak wins a tie.
func longest_streak_text() -> String:
if songs.is_empty():
return "No songs played"
var best_length: int = 0
var best_start: int = 0
var best_player: PlayerObject = null
for player: PlayerObject in players:
var length: int = 0
for song: int in songs.size():
length = length + 1 if songs[song].has(player.id) else 0
var start: int = song - length + 1
if length > best_length || (length == best_length && length > 0 \
&& start < best_start):
best_length = length
best_start = start
best_player = player
if best_length < 2:
return "Nobody scored twice in a row"
return "%s, %d songs in a row (%d–%d)" % [best_player.player_name, best_length,
best_start + 1, best_start + best_length]
## The most songs in a row where nobody scored, e.g. "Songs 6–10, nobody scored".
func longest_standstill_text() -> String:
if songs.is_empty():
return "No songs played"
var best_length: int = 0
var best_start: int = 0
var length: int = 0
for song: int in songs.size():
length = length + 1 if songs[song].is_empty() else 0
if length > best_length:
best_length = length
best_start = song - length + 1
if best_length == 0:
return "Someone scored on every song"
if best_length == 1:
return "Song %d, nobody scored" % (best_start + 1)
return "Songs %d–%d, nobody scored" % [best_start + 1, best_start + best_length]
## The song most players scored on, e.g. "Song 11, Marle and Lucca", or
## "Song 11, 3 players" when more than two did. The earliest song wins a tie.
func most_shared_text() -> String:
if songs.is_empty():
return "No songs played"
var best_song: int = -1
for song: int in songs.size():
if songs[song].size() >= 2 \
&& (best_song < 0 || songs[song].size() > songs[best_song].size()):
best_song = song
if best_song < 0:
return "No song had two scorers"
var scorers: Array = songs[best_song]
if scorers.size() > 2:
return "Song %d, %d players" % [best_song + 1, scorers.size()]
var names: PackedStringArray = []
for player: PlayerObject in players:
if scorers.has(player.id):
names.append(player.player_name)
return "Song %d, %s" % [best_song + 1, " and ".join(names)]
+1
View File
@@ -0,0 +1 @@
uid://dcvwgwhlu80qh
+264 -3
View File
@@ -3,35 +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"
+119
View File
@@ -0,0 +1,119 @@
class_name UploadFolder
extends RefCounted
## A folder picked or dropped to upload as a new soundtrack: its MP3s, ready to send, and
## the other song files, which are left out because the server only takes MP3.
##
## The server wants one folder of songs, so songs in subfolders, such as disc folders,
## are taken too, with the disc's number in front of their names: "Disc 1/01 Title.mp3"
## is sent as "1-01 Title.mp3". With only one disc folder, its songs keep their names.
## Hidden files and folders are left out, and names get rid of the characters the server
## refuses.
## The only kind of song the server takes.
const UPLOAD_EXTENSION: String = "mp3"
## Other song files, listed as left out so the user knows.
const OTHER_SONG_EXTENSIONS: Array[String] = [
"ogg", "wav", "flac", "m4a", "aac", "opus", "wma", "ape", "aif", "aiff",
]
## Characters the server refuses in soundtrack and file names.
const INVALID_NAME_CHARACTERS: String = "/\\:*?\"<>|"
## The folder.
var path: String = ""
## The soundtrack's name to suggest: the folder's name.
var suggested_name: String = ""
## The songs to upload, in order: [code]{"path": String, "name": String, "size": int}[/code],
## with the name to send each one as.
var songs: Array[Dictionary] = []
## The song files left out, as paths in the folder, e.g. [code]"Disc 2/03 Song.flac"[/code].
var skipped: Array[String] = []
## The size of the songs to upload, in bytes.
var total_size: int = 0
## Reads the folder at [param folder].
static func scan(folder: String) -> UploadFolder:
var result: UploadFolder = UploadFolder.new()
result.path = folder.trim_suffix("/").trim_suffix("\\")
result.suggested_name = clean_name(result.path.get_file())
result._add_files(result.path, "", "")
var discs: Array[String] = []
for disc: String in _visible(DirAccess.get_directories_at(result.path)):
if _has_songs(result.path.path_join(disc)):
discs.append(disc)
for disc: int in discs.size():
result._add_folder(result.path.path_join(discs[disc]), discs[disc],
"%d-" % (disc + 1) if discs.size() > 1 else "")
return result
## [param name] without the characters the server refuses, a leading dot, or a trailing
## dot or space.
static func clean_name(name: String) -> String:
var cleaned: String = ""
for character: String in name:
var control: bool = character.unicode_at(0) < 32 || character.unicode_at(0) == 127
cleaned += "-" if INVALID_NAME_CHARACTERS.contains(character) || control \
else character
cleaned = cleaned.strip_edges().lstrip(".")
while cleaned.ends_with(".") || cleaned.ends_with(" "):
cleaned = cleaned.left(-1)
return cleaned
## Whether there is anything to upload.
func has_songs() -> bool:
return !songs.is_empty()
# Adds the files of [param folder] and of the folders in it, all with [param prefix]
func _add_folder(folder: String, relative: String, prefix: String) -> void:
_add_files(folder, relative, prefix)
for inner: String in _visible(DirAccess.get_directories_at(folder)):
_add_folder(folder.path_join(inner), relative.path_join(inner), prefix)
func _add_files(folder: String, relative: String, prefix: String) -> void:
for file_name: String in _visible(DirAccess.get_files_at(folder)):
var extension: String = file_name.get_extension().to_lower()
if extension == UPLOAD_EXTENSION:
var file_path: String = folder.path_join(file_name)
var size: int = FileAccess.get_size(file_path)
songs.append({"path": file_path, "name": _free_name(prefix + clean_name(file_name)),
"size": size})
total_size += size
elif extension in OTHER_SONG_EXTENSIONS:
skipped.append(relative.path_join(file_name) if !relative.is_empty() else file_name)
# [param name], or with " (2)" and so on before its extension when a song already has it
func _free_name(name: String) -> String:
var taken: Array = songs.map(func(song: Dictionary) -> String: return song.name.to_lower())
var candidate: String = name
var number: int = 2
while taken.has(candidate.to_lower()):
candidate = "%s (%d).%s" % [name.get_basename(), number, name.get_extension()]
number += 1
return candidate
# Whether [param folder] or a folder in it has a song, to upload or to list as left out
static func _has_songs(folder: String) -> bool:
for file_name: String in _visible(DirAccess.get_files_at(folder)):
var extension: String = file_name.get_extension().to_lower()
if extension == UPLOAD_EXTENSION || extension in OTHER_SONG_EXTENSIONS:
return true
for inner: String in _visible(DirAccess.get_directories_at(folder)):
if _has_songs(folder.path_join(inner)):
return true
return false
# The names that are not hidden, in natural order, so "2" comes before "10"
static func _visible(names: PackedStringArray) -> Array[String]:
var shown: Array[String] = []
for name: String in names:
if !name.begins_with("."):
shown.append(name)
shown.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
return shown
+1
View File
@@ -0,0 +1 @@
uid://3amk1cte2idb
+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=""
+92
View File
@@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://c325cbuhy0n3d"
path="res://.godot/imported/NotoSansJP-Medium.otf-48029a81cfbb1e3a1e09d84067c2726b.fontdata"
[deps]
source_file="res://fonts/NotoSansJP-Medium.otf"
dest_files=["res://.godot/imported/NotoSansJP-Medium.otf-48029a81cfbb1e3a1e09d84067c2726b.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+88
View File
@@ -0,0 +1,88 @@
Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://bu247t5hvmk2w"
path="res://.godot/imported/OpenSans-SemiBold.woff2-035244bfb0cae573e35cba9e3c76e652.fontdata"
[deps]
source_file="res://fonts/OpenSans-SemiBold.woff2"
dest_files=["res://.godot/imported/OpenSans-SemiBold.woff2-035244bfb0cae573e35cba9e3c76e652.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[Resource("uid://c325cbuhy0n3d", "res://fonts/NotoSansJP-Medium.otf")]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+31
View File
@@ -0,0 +1,31 @@
# Fonts
The theme's default font (`themes/main_theme.tres`) is `OpenSans-SemiBold.woff2`, the
font Godot uses when a project sets none, saved from Godot so the game looks the same.
Text it has no letters for, such as Japanese, falls back to `NotoSansJP-Medium.otf`.
Both are under the SIL Open Font License, see `OpenSans-LICENSE.txt` and
`NotoSansJP-LICENSE.txt`.
`NotoSansJP-Medium.otf` is the Medium weight from
[noto-cjk](https://github.com/notofonts/noto-cjk/tree/main/Sans/SubsetOTF/JP), changed in
one way: its line metrics (`hhea` ascent and descent) are set to Open Sans's. Godot makes
every line as tall as the tallest font in the fallback chain, so with Noto's own, taller
metrics all text in the game would move down and rows would grow, even without any
Japanese on screen. The glyphs are unchanged. To update the font, download it again and
set the metrics with [fontTools](https://github.com/fonttools/fonttools):
```python
from fontTools.ttLib import TTFont
reference = TTFont("OpenSans-SemiBold.woff2") # needs the brotli package
noto = TTFont("NotoSansJP-Medium.otf")
scale = noto["head"].unitsPerEm / reference["head"].unitsPerEm
noto["hhea"].ascent = round(reference["hhea"].ascent * scale)
noto["hhea"].descent = round(reference["hhea"].descent * scale)
noto["hhea"].lineGap = 0
noto.save("NotoSansJP-Medium.otf")
```
`tests/integration/test_theme.gd` checks that Japanese has a font and that the line
height is still Godot's.
+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
+66 -45
View File
@@ -9,6 +9,11 @@
[ext_resource type="PackedScene" uid="uid://btupbowehiyyu" path="res://popups/QRPopup.tscn" id="15_qr_window"]
[ext_resource type="PackedScene" uid="uid://cp54idaudb8dp" path="res://popups/AboutPopup.tscn" id="16_about_window"]
[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"]
[ext_resource type="PackedScene" path="res://popups/UploadPopup.tscn" id="upload_popup_scene"]
[node name="MainWindow" type="Control" unique_id=407149696]
layout_mode = 3
@@ -33,78 +38,94 @@ theme_type_variation = &"WindowBackground"
[node name="Quiz" parent="." unique_id=1357908642 instance=ExtResource("quiz_scene")]
[node name="StatisticsView" parent="." unique_id=1212999001 instance=ExtResource("statistics_view_scene")]
visible = false
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
[node name="StatisticsPopupPanel" type="PopupPanel" parent="." unique_id=298311407]
oversampling_override = 1.0
initial_position = 2
size = Vector2i(450, 100)
[node name="Label" type="Label" parent="StatisticsPopupPanel" unique_id=548316493]
offset_left = 4.0
offset_top = 4.0
offset_right = 446.0
offset_bottom = 96.0
text = "Statistics"
horizontal_alignment = 1
[node name="StatisticsLabel" type="Label" parent="StatisticsPopupPanel" unique_id=58226477]
unique_name_in_owner = true
offset_left = 4.0
offset_top = 4.0
offset_right = 446.0
offset_bottom = 96.0
text = "Total amount of soundtracks in the playlist: 9000"
horizontal_alignment = 1
vertical_alignment = 1
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 = 720.0
offset_top = 153.0
offset_right = 720.0
offset_bottom = 153.0
offset_left = 360.0
offset_top = 220.0
offset_right = 360.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
layout_mode = 1
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
[node name="UploadPopup" parent="." instance=ExtResource("upload_popup_scene")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 580.0
offset_top = 200.0
offset_right = 580.0
offset_bottom = 200.0
+103 -48
View File
@@ -2,13 +2,7 @@
[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]
layout_mode = 3
@@ -23,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
@@ -34,63 +29,123 @@ 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 = 1442.0
offset_top = 8.0
offset_right = 1512.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 = 1354.0
offset_top = 8.0
offset_right = 1424.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="StatisticsButton" type="Button" parent="." unique_id=2045021269]
layout_mode = 0
offset_left = 1530.0
offset_top = 8.0
offset_right = 1600.0
offset_bottom = 48.0
focus_mode = 0
action_mode = 0
text = "Statistics"
[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 = 839.0
offset_top = 10.0
offset_right = 954.0
offset_bottom = 46.0
focus_mode = 0
theme_type_variation = &"TabButton"
toggle_mode = true
button_pressed = true
action_mode = 0
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 = 966.0
offset_top = 10.0
offset_right = 1081.0
offset_bottom = 46.0
focus_mode = 0
theme_type_variation = &"TabButton"
toggle_mode = true
action_mode = 0
button_group = SubResource("ButtonGroup_tabs")
text = "Statistics"
+125 -18
View File
@@ -1,9 +1,12 @@
class_name MainWindow
extends Control
## The main window: the top bar, the popups opened from it and the quiz below it.
## The main window: the top bar, the popups opened from it, and below it the quiz or the
## statistics, depending on the chosen tab.
##
## Everything that is not part of a quiz lives here, so other views can be added next
## to the quiz later.
## 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,38 +19,122 @@ var popup_backdrop: PopupBackdrop
@onready var qr_popup: QRPopup = %QRPopup
@onready var about_popup: AboutPopup = %AboutPopup
@onready var log_popup: LogPopup = %LogPopup
@onready var statistic_popup: PopupPanel = $StatisticsPopupPanel
@onready var statistic_label: Label = %StatisticsLabel
@onready var settings_popup: SettingsPopup = %SettingsPopup
@onready var update_popup: UpdatePopup = %UpdatePopup
@onready var upload_popup: UploadPopup = %UploadPopup
@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.statistics_pressed.connect(get_statistics)
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)
statistics_view.add_soundtrack_pressed.connect(upload_popup.choose_folder)
upload_popup.soundtrack_added.connect(_on_soundtrack_added)
get_window().files_dropped.connect(on_files_dropped)
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()
## Offers to upload a folder dropped on the window as a new soundtrack. Dropped songs
## stand for the folder they are in.
func on_files_dropped(paths: PackedStringArray) -> void:
if paths.is_empty():
return
var folder: String = paths[0]
for path: String in paths:
if DirAccess.dir_exists_absolute(path):
folder = path
break
if !DirAccess.dir_exists_absolute(folder):
folder = folder.get_base_dir()
upload_popup.show_folder(folder)
# A new soundtrack is found by search, listed for inspiration and in the statistics
func _on_soundtrack_added(_soundtrack: SoundtrackWithSongList) -> void:
search_popup.get_list_of_soundtracks()
quiz.inspiration_list.load_soundtracks()
if statistics_view.visible:
statistics_view.refresh()
# 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:
@@ -55,7 +142,15 @@ func _input(event: InputEvent) -> void:
func _popups() -> Array[BasePopup]:
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup]
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
soundtrack_stats_popup, settings_popup, update_popup, upload_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:
@@ -78,11 +173,21 @@ func show_qr() -> void:
qr_popup.open()
func get_statistics() -> void:
GameLog.debug("get_statistics")
statistic_popup.visible = true
statistic_label.text = "Total amount of soundtracks in the playlist: " \
+ str(search_popup.soundtracks.size())
## Shows the quiz for [code]&"quiz"[/code] or the statistics for [code]&"statistics"[/code].
## A hidden quiz keeps playing its song.
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()
func sync_soundtracks() -> void:
@@ -100,3 +205,5 @@ func _on_sync_finished() -> void:
GameLog.debug("_on_sync_finished")
search_popup.get_list_of_soundtracks()
quiz.on_sync_finished()
if statistics_view.visible:
statistics_view.refresh()
+105 -10
View File
@@ -1,22 +1,117 @@
class_name TopBar
extends Control
## The bar along the top of the main window, with the title and the buttons for things
## that are not part of a quiz.
## 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 statistics_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
## 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)
$StatisticsButton.pressed.connect(statistics_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].
func select_tab(tab: StringName) -> void:
quiz_tab.set_pressed_no_signal(tab == &"quiz")
statistics_tab.set_pressed_no_signal(tab == &"statistics")
+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"))
+64 -22
View File
@@ -15,45 +15,87 @@ panel = NodePath("AboutPanel")
[node name="AboutPanel" type="Panel" parent="." unique_id=1907299275]
unique_name_in_owner = true
layout_mode = 0
offset_right = 600.0
offset_right = 1200.0
offset_bottom = 640.0
theme_type_variation = &"PopupBackground"
[node name="TitleLabel" type="Label" parent="AboutPanel" unique_id=200295721]
layout_mode = 0
offset_right = 600.0
offset_right = 1200.0
offset_bottom = 50.0
text = "About"
horizontal_alignment = 1
vertical_alignment = 1
[node name="ScrollContainer" type="ScrollContainer" parent="AboutPanel" unique_id=417677913]
[node name="DescriptionLabel" type="Label" parent="AboutPanel" unique_id=543094263]
layout_mode = 0
offset_left = 20.0
offset_top = 50.0
offset_right = 580.0
offset_bottom = 630.0
horizontal_scroll_mode = 0
[node name="ContentVBox" type="VBoxContainer" parent="AboutPanel/ScrollContainer" unique_id=1746337912]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VersionLabel" type="Label" parent="AboutPanel/ScrollContainer/ContentVBox" unique_id=352884126]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
horizontal_alignment = 1
[node name="DescriptionLabel" type="Label" parent="AboutPanel/ScrollContainer/ContentVBox" unique_id=543094263]
layout_mode = 2
size_flags_horizontal = 3
offset_right = 1180.0
offset_bottom = 80.0
text = "Try your video game music knowledge with this VGM randomizer, invite your friends and see who is the best."
horizontal_alignment = 1
autowrap_mode = 2
[node name="WhatsNewLabel" type="Label" parent="AboutPanel/ScrollContainer/ContentVBox" unique_id=1363040291]
[node name="Columns" type="HBoxContainer" parent="AboutPanel" unique_id=200295722]
layout_mode = 0
offset_left = 20.0
offset_top = 96.0
offset_right = 1180.0
offset_bottom = 630.0
theme_override_constants/separation = 40
[node name="ScrollContainer" type="ScrollContainer" parent="AboutPanel/Columns" unique_id=417677913]
layout_mode = 2
size_flags_horizontal = 3
horizontal_scroll_mode = 0
[node name="ContentVBox" type="VBoxContainer" parent="AboutPanel/Columns/ScrollContainer" unique_id=1746337912]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="PlayerHeader" type="Label" parent="AboutPanel/Columns/ScrollContainer/ContentVBox" unique_id=1746337913]
layout_mode = 2
theme_type_variation = &"HighlightLarge"
text = "Music Player"
horizontal_alignment = 1
[node name="VersionLabel" type="Label" parent="AboutPanel/Columns/ScrollContainer/ContentVBox" unique_id=352884126]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
horizontal_alignment = 1
[node name="WhatsNewLabel" type="Label" parent="AboutPanel/Columns/ScrollContainer/ContentVBox" unique_id=1363040291]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
autowrap_mode = 2
[node name="ServerScrollContainer" type="ScrollContainer" parent="AboutPanel/Columns" unique_id=417677914]
layout_mode = 2
size_flags_horizontal = 3
horizontal_scroll_mode = 0
[node name="ServerContentVBox" type="VBoxContainer" parent="AboutPanel/Columns/ServerScrollContainer" unique_id=1746337914]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="ServerHeader" type="Label" parent="AboutPanel/Columns/ServerScrollContainer/ServerContentVBox" unique_id=1746337915]
layout_mode = 2
theme_type_variation = &"HighlightLarge"
text = "Music Server"
horizontal_alignment = 1
[node name="ServerVersionLabel" type="Label" parent="AboutPanel/Columns/ServerScrollContainer/ServerContentVBox" unique_id=352884127]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
horizontal_alignment = 1
[node name="ServerHistoryLabel" type="Label" parent="AboutPanel/Columns/ServerScrollContainer/ServerContentVBox" unique_id=1363040292]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
+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
+299
View File
@@ -0,0 +1,299 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://popups/soundtrack_stats_popup.gd" id="popup_script"]
[node name="SoundtrackStatsPopup" type="Control" unique_id=1213000001 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("StatsPanel")
[node name="StatsPanel" type="Panel" parent="." unique_id=1213000002]
unique_name_in_owner = true
layout_mode = 0
offset_right = 700.0
offset_bottom = 800.0
theme_type_variation = &"PopupBackground"
[node name="TitleLabel" type="Label" parent="StatsPanel" unique_id=1213000003]
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 12.0
offset_right = 680.0
offset_bottom = 50.0
theme_type_variation = &"Title"
horizontal_alignment = 1
text_overrun_behavior = 3
[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 = 280.0
offset_right = 680.0
offset_bottom = 784.0
horizontal_scroll_mode = 0
[node name="SongList" type="VBoxContainer" parent="StatsPanel/ScrollContainer" unique_id=1213000006]
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")
+122
View File
@@ -0,0 +1,122 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://popups/upload_popup.gd" id="popup_script"]
[node name="UploadPopup" 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("UploadPanel")
[node name="UploadPanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_right = 760.0
offset_bottom = 680.0
theme_type_variation = &"PopupBackground"
[node name="Rows" type="VBoxContainer" parent="UploadPanel"]
layout_mode = 0
offset_left = 24.0
offset_top = 16.0
offset_right = 736.0
offset_bottom = 664.0
theme_override_constants/separation = 10
[node name="TitleLabel" type="Label" parent="UploadPanel/Rows"]
layout_mode = 2
theme_type_variation = &"Title"
text = "Add a soundtrack"
horizontal_alignment = 1
[node name="HelpLabel" type="Label" parent="UploadPanel/Rows"]
layout_mode = 2
autowrap_mode = 3
text = "The songs are uploaded to the server, which adds them as a new soundtrack right away. Songs in disc folders are added too, with the disc's number first."
[node name="NameRow" type="HBoxContainer" parent="UploadPanel/Rows"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="NameTitle" type="Label" parent="UploadPanel/Rows/NameRow"]
layout_mode = 2
theme_type_variation = &"SettingsRowLabel"
text = "Name"
[node name="NameField" type="LineEdit" parent="UploadPanel/Rows/NameRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Soundtrack name"
max_length = 200
[node name="ChooseFolderButton" type="Button" parent="UploadPanel/Rows/NameRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Other folder…"
[node name="FolderLabel" type="Label" parent="UploadPanel/Rows"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"TextMedium"
text_overrun_behavior = 3
[node name="SongScroll" type="ScrollContainer" parent="UploadPanel/Rows"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="SongList" type="VBoxContainer" parent="UploadPanel/Rows/SongScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="SummaryLabel" type="Label" parent="UploadPanel/Rows"]
unique_name_in_owner = true
layout_mode = 2
autowrap_mode = 3
[node name="UploadProgress" type="ProgressBar" parent="UploadPanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
show_percentage = false
[node name="StatusLabel" type="Label" parent="UploadPanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
autowrap_mode = 3
[node name="Buttons" type="HBoxContainer" parent="UploadPanel/Rows"]
layout_mode = 2
theme_override_constants/separation = 12
alignment = 2
[node name="CancelButton" type="Button" parent="UploadPanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Cancel"
[node name="UploadButton" type="Button" parent="UploadPanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Upload"
[node name="FolderDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Choose a soundtrack folder"
initial_position = 2
size = Vector2i(800, 500)
ok_button_text = "Select Current Folder"
file_mode = 2
access = 2
use_native_dialog = true
+100 -16
View File
@@ -2,6 +2,7 @@
[ext_resource type="Script" uid="uid://dxpb22xbwf3t1" path="res://popups/winner_popup.gd" id="1_winner_script"]
[ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="2_no_character"]
[ext_resource type="Script" path="res://popups/score_graph.gd" id="3_score_graph"]
[node name="WinnerPopup" type="Control" unique_id=1111111111 node_paths=PackedStringArray("panel")]
layout_mode = 3
@@ -16,37 +17,120 @@ panel = NodePath("WinnerPanel")
[node name="WinnerPanel" type="Panel" parent="." unique_id=89212869]
unique_name_in_owner = true
layout_mode = 0
offset_right = 500.0
offset_bottom = 600.0
offset_right = 1600.0
offset_bottom = 920.0
theme_type_variation = &"PopupBackground"
[node name="VBoxContainer" type="VBoxContainer" parent="WinnerPanel" unique_id=471159349]
[node name="GameColumn" type="VBoxContainer" parent="WinnerPanel" unique_id=471159351]
layout_mode = 0
offset_top = 25.0
offset_right = 500.0
offset_bottom = 282.0
size_flags_horizontal = 3
size_flags_vertical = 3
alignment = 1
offset_left = 24.0
offset_top = 20.0
offset_right = 1576.0
offset_bottom = 900.0
theme_override_constants/separation = 12
[node name="WinnerPicture" type="TextureRect" parent="WinnerPanel/VBoxContainer" unique_id=1543774067]
[node name="Header" type="HBoxContainer" parent="WinnerPanel/GameColumn" unique_id=471159349]
layout_mode = 2
alignment = 1
theme_override_constants/separation = 32
[node name="WinnerPicture" type="TextureRect" parent="WinnerPanel/GameColumn/Header" unique_id=1543774067]
unique_name_in_owner = true
custom_minimum_size = Vector2(480, 200)
custom_minimum_size = Vector2(360, 180)
layout_mode = 2
texture = ExtResource("2_no_character")
expand_mode = 3
stretch_mode = 5
[node name="WinnerLabel" type="Label" parent="WinnerPanel/VBoxContainer" unique_id=1437597286]
[node name="WinnerInfo" type="VBoxContainer" parent="WinnerPanel/GameColumn/Header" unique_id=471159367]
custom_minimum_size = Vector2(520, 0)
layout_mode = 2
alignment = 1
[node name="WinnerLabel" type="Label" parent="WinnerPanel/GameColumn/Header/WinnerInfo" unique_id=1437597286]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"TitleMedium"
text = "Winner!"
horizontal_alignment = 1
vertical_alignment = 2
autowrap_mode = 3
[node name="PlayersList" type="VBoxContainer" parent="WinnerPanel/VBoxContainer" unique_id=471159350]
[node name="PlayersList" type="VBoxContainer" parent="WinnerPanel/GameColumn/Header/WinnerInfo" unique_id=471159350]
unique_name_in_owner = true
layout_mode = 2
[node name="Legend" type="HBoxContainer" parent="WinnerPanel/GameColumn" unique_id=471159352]
unique_name_in_owner = true
layout_mode = 2
alignment = 1
theme_override_constants/separation = 20
[node name="ScoreGraph" type="Control" parent="WinnerPanel/GameColumn" unique_id=471159353]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
mouse_filter = 2
script = ExtResource("3_score_graph")
[node name="Summary" type="HBoxContainer" parent="WinnerPanel/GameColumn" unique_id=471159354]
layout_mode = 2
theme_override_constants/separation = 12
[node name="StreakBox" type="PanelContainer" parent="WinnerPanel/GameColumn/Summary" unique_id=471159355]
custom_minimum_size = Vector2(0, 84)
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"ListRowEven"
[node name="VBox" type="VBoxContainer" parent="WinnerPanel/GameColumn/Summary/StreakBox" unique_id=471159356]
layout_mode = 2
[node name="Header" type="Label" parent="WinnerPanel/GameColumn/Summary/StreakBox/VBox" unique_id=471159357]
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Longest streak"
[node name="StreakLabel" type="Label" parent="WinnerPanel/GameColumn/Summary/StreakBox/VBox" unique_id=471159358]
unique_name_in_owner = true
custom_minimum_size = Vector2(1, 0)
layout_mode = 2
autowrap_mode = 3
[node name="StandstillBox" type="PanelContainer" parent="WinnerPanel/GameColumn/Summary" unique_id=471159359]
custom_minimum_size = Vector2(0, 84)
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"ListRowEven"
[node name="VBox" type="VBoxContainer" parent="WinnerPanel/GameColumn/Summary/StandstillBox" unique_id=471159360]
layout_mode = 2
[node name="Header" type="Label" parent="WinnerPanel/GameColumn/Summary/StandstillBox/VBox" unique_id=471159361]
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Longest standstill"
[node name="StandstillLabel" type="Label" parent="WinnerPanel/GameColumn/Summary/StandstillBox/VBox" unique_id=471159362]
unique_name_in_owner = true
custom_minimum_size = Vector2(1, 0)
layout_mode = 2
autowrap_mode = 3
[node name="SharedBox" type="PanelContainer" parent="WinnerPanel/GameColumn/Summary" unique_id=471159363]
custom_minimum_size = Vector2(0, 84)
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"ListRowEven"
[node name="VBox" type="VBoxContainer" parent="WinnerPanel/GameColumn/Summary/SharedBox" unique_id=471159364]
layout_mode = 2
[node name="Header" type="Label" parent="WinnerPanel/GameColumn/Summary/SharedBox/VBox" unique_id=471159365]
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Most shared song"
[node name="SharedLabel" type="Label" parent="WinnerPanel/GameColumn/Summary/SharedBox/VBox" unique_id=471159366]
unique_name_in_owner = true
custom_minimum_size = Vector2(1, 0)
layout_mode = 2
autowrap_mode = 3
+37 -7
View File
@@ -1,6 +1,7 @@
class_name AboutPopup
extends BasePopup
## Shows the game version and what is new in each version.
## Shows the game's and the server's version, and what is new in each of their versions,
## side by side.
## What is new in each version, newest first.
const WHATS_NEW: Dictionary = {
@@ -57,16 +58,45 @@ const WHATS_NEW: Dictionary = {
@onready var about_panel: Panel = %AboutPanel
@onready var version_label: Label = %VersionLabel
@onready var whats_new_label: Label = %WhatsNewLabel
@onready var server_version_label: Label = %ServerVersionLabel
@onready var server_history_label: Label = %ServerHistoryLabel
func open() -> void:
super()
version_label.text = Settings.version
# Build changelog text with version headers
var changelog_text: String = ""
var entries: Array = []
for version: String in WHATS_NEW.keys():
changelog_text += "\n" + version + "\n"
changelog_text += WHATS_NEW[version] + "\n"
entries.append([version, [WHATS_NEW[version]]])
whats_new_label.text = changelog_text(entries)
whats_new_label.text = changelog_text
# Asked every time, as another server can be picked in the settings
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)
## The changelog text for [param entries], each a [code][version, lines][/code] pair, newest
## first: every version on its own line, followed by its lines, with an empty line
## between versions.
static func changelog_text(entries: Array) -> String:
var versions: PackedStringArray = []
for entry: Array in entries:
versions.append(entry[0] + "\n" + "\n".join(entry[1]))
return "\n\n".join(versions)
func _show_server_history(history: Array[VersionData]) -> void:
server_version_label.text = history[0].version if !history.is_empty() else ""
var entries: Array = []
for version: VersionData in history:
entries.append([version.version, version.changelog])
server_history_label.text = changelog_text(entries)
func _show_server_history_failed() -> void:
server_version_label.text = "Could not reach the server"
server_history_label.text = ""
+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):
+53 -36
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):
GameLog.debug(character_name_label.text)
new_character_selected.emit(character_image.texture)
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 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)
## 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, character_name)
+38 -16
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:
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.new_character_selected.connect(_on_character_selected)
MusicServerClient.fetch_character_list(fetch_character_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.set_character_name(character_name, loading.fetch_image)
character_box.new_character_selected.connect(_on_character_selected)
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
+206
View File
@@ -0,0 +1,206 @@
class_name ScoreGraph
extends Control
## Draws every player's score after each song of the game as a step line, so streaks and
## stretches where nobody scored stand out. Shown on the Winner popup.
##
## Colours and sizes come from the theme's [code]ScoreGraph[/code] type. Each player gets
## the colour of their position in [code]Settings.player_array[/code].
const THEME_TYPE: StringName = &"ScoreGraph"
## Room for the score labels on the left and right, and the song labels below.
const MARGIN_LEFT: float = 32.0
const MARGIN_RIGHT: float = 32.0
const MARGIN_TOP: float = 22.0
const MARGIN_BOTTOM: float = 44.0
## About how many song numbers fit along the bottom.
const MAX_SONG_LABELS: int = 20
## How much of the lines is drawn, from 0 (none) to 1 (all), so they can be drawn from
## left to right.
var progress: float = 1.0:
set(value):
progress = value
queue_redraw()
var _history: ScoreHistory
var _winning_score: int = 0
func _notification(what: int) -> void:
# The colours come from the theme, which can be switched in Settings
if what == NOTIFICATION_THEME_CHANGED:
queue_redraw()
## Shows [param history]. The y axis goes up to [param winning_score], or higher if
## someone has more points. A winning score of 0 draws no winning line.
func show_history(history: ScoreHistory, winning_score: int) -> void:
_history = history
_winning_score = winning_score
queue_redraw()
## Draws the lines from left to right over [param duration] seconds.
func draw_lines_in(duration: float = 1.2) -> void:
var draw_tween: Tween = Anim.tween(self, &"draw")
progress = 1.0
if draw_tween == null:
return
progress = 0.0
draw_tween.tween_property(self, "progress", 1.0, duration) \
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
## The part of [param points] left of [param limit_x], cut where the line crosses it.
static func clip_line(points: PackedVector2Array, limit_x: float) -> PackedVector2Array:
var clipped: PackedVector2Array = []
for i: int in points.size():
if points[i].x <= limit_x:
clipped.append(points[i])
continue
if i > 0 && points[i - 1].x < limit_x:
var before: Vector2 = points[i - 1]
var weight: float = (limit_x - before.x) / (points[i].x - before.x)
clipped.append(before.lerp(points[i], weight))
break
return clipped
## The colour of the player at [param index] in the player list.
func player_color(index: int) -> Color:
return get_theme_color(StringName("player_%d" % (index % 7 + 1)), THEME_TYPE)
## Where the line of the player at [param index] is drawn after each song, starting
## before the first song.
func line_points(index: int) -> PackedVector2Array:
var points: PackedVector2Array = []
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
points.append(_point(0, 0, index))
for song: int in range(1, scores.size()):
if scores[song] != scores[song - 1]:
points.append(_point(song, scores[song - 1], index))
points.append(_point(song, scores[song], index))
points.append(_point(scores.size() - 1, scores.back(), index))
return points
func _draw() -> void:
if _history == null:
return
var font: Font = get_theme_default_font()
var font_size: int = get_theme_font_size(&"font_size", THEME_TYPE)
var text_color: Color = get_theme_color(&"axis_text", THEME_TYPE)
_draw_score_axis(font, font_size, text_color)
_draw_song_axis(font, font_size, text_color)
var line_width: int = get_theme_constant(&"line_width", THEME_TYPE)
var dot_radius: int = get_theme_constant(&"dot_radius", THEME_TYPE)
var limit_x: float = lerpf(MARGIN_LEFT, size.x - MARGIN_RIGHT, progress)
for index: int in _history.players.size():
var color: Color = player_color(index)
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
var points: PackedVector2Array = clip_line(line_points(index), limit_x)
if points.size() >= 2:
draw_polyline(points, color, line_width, true)
for song: int in range(1, scores.size()):
var dot: Vector2 = _point(song, scores[song], index)
if scores[song] > scores[song - 1] && dot.x <= limit_x:
draw_circle(dot, dot_radius, color)
# The final scores are written once the lines have reached them
if progress < 1.0:
return
var label_positions: Array[Vector2] = score_label_positions()
for index: int in _history.players.size():
draw_string(font, label_positions[index],
str(_history.final_score(_history.players[index].id)),
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, player_color(index))
## Where each player's score is written at the end of their line, as the baseline of the
## text. Scores that would overlap are moved apart.
func score_label_positions() -> Array[Vector2]:
var font_size: int = get_theme_font_size(&"font_size", THEME_TYPE)
var positions: Array[Vector2] = []
for index: int in _history.players.size():
var score: int = _history.final_score(_history.players[index].id)
var end: Vector2 = _point(_history.songs.size(), score, index)
positions.append(end + Vector2(8, font_size * 0.35))
# From the top down, push each label below the one above it
var order: Array = range(positions.size())
order.sort_custom(func(a: int, b: int) -> bool: return positions[a].y < positions[b].y)
for i: int in range(1, order.size()):
var above: Vector2 = positions[order[i - 1]]
if positions[order[i]].y - above.y < font_size:
positions[order[i]].y = above.y + font_size
return positions
## The songs whose numbers are written along the bottom.
func labelled_songs() -> Array[int]:
var song_count: int = _history.songs.size()
var step: int = maxi(1, ceili(song_count / float(MAX_SONG_LABELS)))
var songs: Array[int] = []
for song: int in range(1, song_count + 1):
# The last song is always labelled, unless that would crowd its neighbour
var is_last: bool = song == song_count && (song - 1) % step >= step / 2.0
if (song - 1) % step == 0 || is_last:
songs.append(song)
return songs
## Where on the x axis [param song] is, counting the start of the game as song 0.
func song_x(song: int) -> float:
return _x(song)
func _draw_score_axis(font: Font, font_size: int, text_color: Color) -> void:
var grid_color: Color = get_theme_color(&"grid", THEME_TYPE)
var top: int = _top_score()
var step: int = maxi(1, ceili(top / 10.0))
for score: int in range(0, top + 1, step):
var y: float = _y(score)
draw_line(Vector2(MARGIN_LEFT, y), Vector2(size.x - MARGIN_RIGHT, y), grid_color, 1)
draw_string(font, Vector2(0, y + font_size * 0.35), str(score),
HORIZONTAL_ALIGNMENT_RIGHT, MARGIN_LEFT - 8, font_size, text_color)
if _winning_score <= 0:
return
var winning_color: Color = get_theme_color(&"winning_line", THEME_TYPE)
var winning_y: float = _y(_winning_score)
draw_dashed_line(Vector2(MARGIN_LEFT, winning_y), Vector2(size.x - MARGIN_RIGHT, winning_y),
winning_color, 1, 6)
draw_string(font, Vector2(MARGIN_LEFT + 4, winning_y - 6), "winning score",
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, winning_color)
func _draw_song_axis(font: Font, font_size: int, text_color: Color) -> void:
var label_y: float = size.y - MARGIN_BOTTOM + font_size + 6
for song: int in labelled_songs():
draw_string(font, Vector2(_x(song) - 20, label_y), str(song),
HORIZONTAL_ALIGNMENT_CENTER, 40, font_size, text_color)
draw_string(font, Vector2(0, size.y - 4), "Song", HORIZONTAL_ALIGNMENT_CENTER, size.x,
font_size, text_color)
func _point(song: int, score: int, index: int) -> Vector2:
# Lines of players with the same score sit side by side instead of on top of each other
var spread: float = get_theme_constant(&"line_width", THEME_TYPE) \
* (index - (_history.players.size() - 1) / 2.0)
return Vector2(_x(song), _y(score) + spread)
func _x(song: int) -> float:
var width: float = size.x - MARGIN_LEFT - MARGIN_RIGHT
return MARGIN_LEFT + song * width / maxi(_history.songs.size(), 1)
func _y(score: int) -> float:
var height: float = size.y - MARGIN_TOP - MARGIN_BOTTOM
return size.y - MARGIN_BOTTOM - score * height / _top_score()
func _top_score() -> int:
var top: int = maxi(_winning_score, 1)
for player: PlayerObject in _history.players:
top = maxi(top, _history.final_score(player.id))
return top
+1
View File
@@ -0,0 +1 @@
uid://ccxvqduq1wmhp
+156 -164
View File
@@ -1,35 +1,34 @@
class_name SearchPopup
extends BasePopup
## Searches every soundtrack on the server as you type. Matches ignore case, spaces and
## special characters, and also find the letters in order with others in between.
## 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 = []
## Each soundtrack as a Dictionary with its original, lowercase and cleaned name.
var precomputed_soundtracks: Array = []
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 = {}
## Compiled patterns for the letters-in-order search, by cleaned search term.
var regex_cache: Dictionary = {}
var label_pool: Array[Label] = []
@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:
@@ -37,160 +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 find_matches(search_text):
add_soundtrack_reuse(soundtrack)
## Returns the soundtracks matching [param search_text], best matches first: plain
## case-insensitive matches, then matches ignoring spaces and special characters,
## then matches with the letters in order but not next to each other.
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 matches: Array[String] = []
# Pass 1: Exact case-insensitive contains match on lowercase versions
for i: int in range(precomputed_soundtracks.size()):
var st_data: Dictionary = precomputed_soundtracks[i]
if st_data["lowercase"].contains(search_text_lower):
if not st_data["original"] in seen:
seen[st_data["original"]] = true
matches.append(st_data["original"])
# Pass 2: Contains match on cleaned terms
for i: int in range(precomputed_soundtracks.size()):
var st_data: Dictionary = precomputed_soundtracks[i]
if st_data["cleaned"].contains(clean_search_text):
if not st_data["original"] in seen:
seen[st_data["original"]] = true
matches.append(st_data["original"])
# Pass 3: Regex match on cleaned terms
var regex: RegEx = compile_regex_cached(clean_search_text)
for i: int in range(precomputed_soundtracks.size()):
var st_data: Dictionary = precomputed_soundtracks[i]
if regex.search(st_data["cleaned"]):
if not st_data["original"] in seen:
seen[st_data["original"]] = true
matches.append(st_data["original"])
return matches
## Cleans a term by removing spaces and replacing special characters.
## Matches MusicServer's clean_term() function.
func clean_term(term: String) -> String:
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \
.replace("'n", "and").to_lower()
## 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:
var regex: RegEx = RegEx.new()
var regex_text: String = ".*"
for letter: String in search_term:
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
if "\\^$.|?*+()[]{}".contains(letter):
regex_text += "\\"
regex_text += letter + ".*"
regex.compile(regex_text)
regex_cache[search_term] = regex
return regex_cache[search_term]
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 and precomputes their search forms.
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 = []
precomputed_soundtracks = []
soundtracks.append_array(list)
for soundtrack: String in soundtracks:
precomputed_soundtracks.append({
"original": soundtrack,
"lowercase": soundtrack.to_lower(),
"cleaned": clean_term(soundtrack)
})
_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)
+62 -48
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():
var panel: PanelContainer = PanelContainer.new()
var hbox: HBoxContainer = HBoxContainer.new()
hbox.alignment = BoxContainer.ALIGNMENT_CENTER
## 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()
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
panel.add_child(hbox)
return panel
+305
View File
@@ -0,0 +1,305 @@
class_name SoundtrackStatsPopup
extends BasePopup
## 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
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" % [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(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)
+1
View File
@@ -0,0 +1 @@
uid://sjteu2abyr0f
+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
+220
View File
@@ -0,0 +1,220 @@
class_name UploadPopup
extends BasePopup
## Adds a new soundtrack to the server from a folder: picked with Add soundtrack… in the
## statistics, or dropped on the window.
##
## Lists the folder's songs (see [UploadFolder]), lets the name be changed, and uploads
## the songs one at a time with a progress bar. A song that fails is sent again a couple
## of times before the upload is cancelled. The server then adds the soundtrack at once,
## without a sync.
## Emitted when the server has added the soundtrack.
signal soundtrack_added(soundtrack: SoundtrackWithSongList)
## How many times a song that failed is sent again before the upload is given up.
const RETRIES: int = 2
## The folder shown.
var folder: UploadFolder
# The upload on the server, while there is one
var _upload_id: String = ""
# The song being sent, and how often it failed
var _next: int = 0
var _failures: int = 0
var _uploading: bool = false
@onready var name_field: LineEdit = %NameField
@onready var choose_folder_button: Button = %ChooseFolderButton
@onready var folder_label: Label = %FolderLabel
@onready var song_list: VBoxContainer = %SongList
@onready var summary_label: Label = %SummaryLabel
@onready var upload_progress: ProgressBar = %UploadProgress
@onready var status_label: Label = %StatusLabel
@onready var cancel_button: Button = %CancelButton
@onready var upload_button: Button = %UploadButton
@onready var folder_dialog: FileDialog = %FolderDialog
func _ready() -> void:
choose_folder_button.pressed.connect(folder_dialog.popup_centered)
folder_dialog.dir_selected.connect(show_folder)
cancel_button.pressed.connect(close)
upload_button.pressed.connect(upload)
name_field.text_changed.connect(func(_text: String) -> void: _update_buttons())
name_field.text_submitted.connect(func(_text: String) -> void: upload())
## Asks for a folder, then shows it.
func choose_folder() -> void:
folder_dialog.popup_centered()
## Lists the songs in the folder at [param path] and opens the popup.
func show_folder(path: String) -> void:
folder = UploadFolder.scan(path)
name_field.text = folder.suggested_name
folder_label.text = "From " + short_path(folder.path)
folder_label.tooltip_text = folder.path
_fill_song_list()
summary_label.text = summary_text(folder)
upload_progress.visible = false
status_label.visible = false
upload_button.visible = true
cancel_button.text = "Cancel"
if Settings.is_offline():
_show_status("Adding a soundtrack needs the server, not a music folder.")
elif !folder.has_songs():
_show_status("There are no MP3 files in %s." % folder.path.get_file())
_set_uploading(false)
open()
## Starts uploading the songs as a soundtrack with the name in the name field.
func upload() -> void:
if upload_button.disabled || !upload_button.visible:
return
if Settings.currently_syncing:
_show_status("The server is syncing. Try again when it is done.")
return
_set_uploading(true)
upload_progress.max_value = folder.songs.size()
upload_progress.value = 0
upload_progress.visible = true
_show_status("Starting the upload…")
MusicServerClient.start_upload(UploadFolder.clean_name(name_field.text), _on_started,
func() -> void: _stop("Could not start the upload"))
## Stops an upload under way, and closes the popup.
func close() -> void:
if _uploading:
_uploading = false
_cancel_on_server()
super()
## The last two parts of [param path], e.g. "…/Soundtracks/Chrono Trigger", since the
## start of a long path says the least.
static func short_path(path: String) -> String:
var parts: PackedStringArray = path.replace("\\", "/").split("/", false)
if parts.size() <= 2:
return path
return "…/" + "/".join(parts.slice(-2))
## "30 songs, 245.1 MB", and how many files are left out.
static func summary_text(shown: UploadFolder) -> String:
var text: String = "%d %s, %s" % [shown.songs.size(),
"song" if shown.songs.size() == 1 else "songs",
SettingsPopup.size_text(shown.total_size)]
if !shown.skipped.is_empty():
text += ". %d %s left out: only MP3 files can be uploaded." % [shown.skipped.size(),
"file" if shown.skipped.size() == 1 else "files"]
return text
func _on_started(upload_info: Upload) -> void:
if !_uploading:
# Closed while the server was answering
MusicServerClient.cancel_upload(upload_info.id)
return
_upload_id = upload_info.id
_next = 0
_failures = 0
_send_next()
func _send_next() -> void:
if !_uploading:
return
if _next >= folder.songs.size():
_show_status("Adding the soundtrack…")
MusicServerClient.finish_upload(_upload_id, _on_finished,
func() -> void: _stop("Could not add the soundtrack"))
return
var song: Dictionary = folder.songs[_next]
_show_status("Uploading %d of %d: %s" % [_next + 1, folder.songs.size(), song.name])
# One song at a time, so only one is held in memory
var bytes: PackedByteArray = FileAccess.get_file_as_bytes(song.path)
if bytes.is_empty():
MusicServerClient.last_error = "the file could not be read"
_stop("Could not upload " + song.name)
return
MusicServerClient.upload_song(_upload_id, song.name, bytes, _on_song_sent, _on_song_failed)
func _on_song_sent() -> void:
_next += 1
_failures = 0
upload_progress.value = _next
_send_next()
func _on_song_failed() -> void:
_failures += 1
if _failures <= RETRIES && _uploading:
_send_next()
return
_stop("Could not upload " + folder.songs[_next].name)
func _on_finished(soundtrack: SoundtrackWithSongList) -> void:
_upload_id = ""
_uploading = false
upload_progress.value = upload_progress.max_value
_show_status("Added %s with %d %s." % [soundtrack.name, soundtrack.songs.size(),
"song" if soundtrack.songs.size() == 1 else "songs"])
upload_button.visible = false
cancel_button.text = "Close"
_set_uploading(false)
soundtrack_added.emit(soundtrack)
# Gives up, says why with the server's reason, and removes what was uploaded
func _stop(what: String) -> void:
var reason: String = MusicServerClient.last_error
_cancel_on_server()
_uploading = false
upload_progress.visible = false
_set_uploading(false)
_show_status("%s: %s." % [what,
reason if !reason.is_empty() else "the server could not be reached"])
func _cancel_on_server() -> void:
if !_upload_id.is_empty():
MusicServerClient.cancel_upload(_upload_id)
_upload_id = ""
func _set_uploading(uploading: bool) -> void:
_uploading = uploading
name_field.editable = !uploading
choose_folder_button.disabled = uploading
_update_buttons()
func _update_buttons() -> void:
upload_button.disabled = _uploading || folder == null || !folder.has_songs() \
|| UploadFolder.clean_name(name_field.text).is_empty() || Settings.is_offline()
func _fill_song_list() -> void:
Settings.delete_children(song_list)
var row: int = 0
for song: Dictionary in folder.songs:
song_list.add_child(ListRow.create("%s · %s" % [song.name,
SettingsPopup.size_text(song.size)], row, &"TextMedium"))
row += 1
for skipped: String in folder.skipped:
var list_row: ListRow = ListRow.create("%s · not MP3, left out" % skipped, row,
&"TextMedium")
list_row.label.self_modulate.a = 0.5
song_list.add_child(list_row)
row += 1
func _show_status(message: String) -> void:
status_label.text = message
status_label.visible = true
+1
View File
@@ -0,0 +1 @@
uid://utn0m7614g61
+159 -26
View File
@@ -1,11 +1,25 @@
class_name WinnerPopup
extends BasePopup
## Shows who won, with their character, and where the other players placed.
## 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)
@onready var winner_panel: Panel = %WinnerPanel
@onready var winner_picture: TextureRect = %WinnerPicture
@onready var winner_label: Label = %WinnerLabel
@onready var players_list: VBoxContainer = %PlayersList
@onready var legend: HBoxContainer = %Legend
@onready var score_graph: ScoreGraph = %ScoreGraph
@onready var streak_label: Label = %StreakLabel
@onready var standstill_label: Label = %StandstillLabel
@onready var shared_label: Label = %SharedLabel
func _ready() -> void:
@@ -13,38 +27,157 @@ func _ready() -> void:
func show_winner(winner: PlayerObject) -> void:
visible = true
# Set winner info
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
winner_picture.custom_minimum_size = Vector2(480, 200)
winner_picture.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
winner_picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
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.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
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:
# 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()
for child: Node in legend.get_children():
legend.remove_child(child)
child.queue_free()
for index: int in history.players.size():
var player: PlayerObject = history.players[index]
var entry: HBoxContainer = HBoxContainer.new()
var square: ColorRect = ColorRect.new()
square.custom_minimum_size = LEGEND_SQUARE_SIZE
square.size_flags_vertical = Control.SIZE_SHRINK_CENTER
square.color = score_graph.player_color(index)
entry.add_child(square)
var name_label: Label = Label.new()
name_label.text = "%s %d" % [player.player_name, history.final_score(player.id)]
entry.add_child(name_label)
legend.add_child(entry)
+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
+42 -62
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 = 720.0
offset_top = 153.0
offset_right = 720.0
offset_bottom = 153.0
offset_left = 160.0
offset_top = 80.0
offset_right = 160.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"
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff. Show more