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
Sansan 8b71723a34 Put the whole look into one project theme
Build / test (pull_request) Successful in 31s
Build / build (pull_request) Skipped
Build / test (push) Successful in 31s
Build / build (push) Successful in 1m13s
themes/main_theme.tres is the project theme. It holds Godot's default look for
every control type the game uses, plus named styles for the game's own look
(popups, the background, spacers, list rows, title and text sizes, highlights)
and a palette for the popup dimming. Scenes and scripts name these styles
instead of setting colours, sizes or style boxes, and the five old theme files
are gone. themes/README.md explains the styles and how to make a new theme.

The screenshot tour now uses the real character pictures, so the layout
baseline is taken again. Screenshots are pixel-identical to before, and
test_theme.gd checks that the look stays in the theme.
2026-10-06 13:33:35 +02:00
Sansan a7bf38c5f9 Version 2.0.0
Build / test (pull_request) Successful in 31s
Build / build (pull_request) Skipped
2026-10-06 12:04:42 +02:00
Sansan 5519afb5f6 Add what's new in 2.0.0 to the About popup
The layout test lets the changelog grow in height with its text, and still
checks its position and width.
2026-10-06 12:02:55 +02:00
Sansan d77b72159d Sort the scripts and scenes into folders
main/ holds the main window and top bar, quiz/ the quiz and its parts,
player/ the music player, popups/ every popup and what they share, data/ the
songs, players, playlist and settings, server/ the MusicServer client,
common/ the list row and GameLog, and themes/ the themes and style boxes.
2026-10-06 11:57:29 +02:00
Sansan a0428e3475 Follow the GDScript style guide in every script
- Types on every parameter, return value and variable, and snake_case names
  (the player name limit is the constant MAX_NAME_LENGTH).
- Spelling fixes: *_triggered, character_selected, separator, caught_errors.
  The server's catched_errors key stays as the server sends it.
- Signals are connected with signal.connect() instead of by name.
- Code is ordered signals, constants, exported vars, vars, @onready vars,
  functions, with @onready on one line and two blank lines between functions.
- Every script starts with a ## description. Template comments, old LOC notes
  and commented-out code are gone, apart from the local play code.
- Lines are at most 100 characters, apart from the local play path.
- The screenshot tour turns off the blinking text cursor so pictures match.
2026-10-06 11:53:26 +02:00
Sansan 674604425f Reach nodes by unique name instead of long paths
Nodes a script uses through a path of several nodes are marked unique in their
scene and found with %Name. The search list is renamed SearchResults, and the
player row's nodes get their real types.
2026-10-06 11:44:47 +02:00
Sansan 390da577d7 Name popups *Popup and give scripts, scenes and classes matching names
Every popup is now called *Popup instead of *Window in its file, root node,
node name, class and variable, and PopupWindow becomes BasePopup. Player is
PlayerRow, SongListObject is SongRow and Character is CharacterCard. Script
files are snake_case and scene files PascalCase, each matching its class or
root node, and LightGrayTheme.tres is light_gray_theme.tres.
2026-10-06 11:22:39 +02:00
Sansan b7d67b573e Give every script a class name and use it for typed references
The popups, the volume slider, the player name field and the main window get
a class_name, so the main window and the quiz hold them by their real type
instead of Control. The classes already use the names the files will get.
2026-10-06 11:16:09 +02:00
Sansan 5831e1e73d Log through a GameLog autoload instead of print()
GameLog.debug() prints only in debug builds, and GameLog.warning() reports
problems such as unexpected server data or failed downloads.
2026-10-06 11:15:03 +02:00
Sansan 5cdec5cc8f Move the log popup to the main window
The quiz reports point changes with an event_logged signal, and the main
window adds them to the log. Alt+L is handled by the main window.
2026-10-06 11:13:11 +02:00
Sansan e94c75e84d Split now playing, the quiz buttons and song loading out of the quiz
NowPlaying, QuizToolbar and SongControls become their own scenes, the auto
repeat switch moves into the music player scene, and downloading songs moves
into a SongLoader node. The quiz only wires the parts together. Every node
keeps its place on screen.

Screenshots of every UI state are pixel-identical to before, the layout test
still matches the original baseline, and each new part has its own tests.
2026-10-06 11:11:18 +02:00
Sansan bad4b3ad8b Indent every script with tabs, like the Godot editor does 2026-10-06 10:28:12 +02:00
Sansan 8c1987807c Make the music list, player list and inspiration list their own scenes
MusicListPanel, PlayersPanel (was Players) and InspirationList (was
ScrollContainer) each get their own scene and script. They report what
happens through signals, and the quiz wires them to the playlist, the
music player, the log and the popups. Every node keeps its place on screen.

Screenshots of every UI state are pixel-identical to before, the layout
test still matches the original baseline, and each part is tested on its
own as well as through the quiz.
2026-10-06 10:27:35 +02:00
Sansan 3c7b4e5c9d Split the main window into TopBar and Quiz scenes
The quiz (song, playlist, players, music player and the popups opened from
it) moves into Quiz.tscn and the top panel into TopBar.tscn. The main window
keeps only the top bar, the quiz and the popups opened from the top bar, so
other views can be added next to the quiz later. Every node keeps its place
on screen.

Screenshots of every UI state before and after are pixel-identical, and a
layout test checks each node against a baseline taken before the split.
2026-10-06 10:02:22 +02:00
Sansan 75c59eb223 Move Sync to the top row and Settings next to the reset buttons 2026-10-06 08:43:05 +02:00
Sansan 10e9e7b2a4 New app icon, packaged with a menu entry for Linux
Build / test (pull_request) Successful in 22s
Build / build (pull_request) Skipped
The icon is a plate wearing headphones with a red Nordic house cookie
on it. It replaces the default Godot icon, so the game window, Windows
and macOS builds use it.

Linux programs can't carry their own icon, so the build now also makes
MusicPlayer_<version>_linux.tar.gz with the game, the icon as a PNG, a
.desktop menu entry and install.sh, which installs them for the current
user. The bare Linux binary is still published.
2026-10-06 08:32:56 +02:00
Sansan 29b11560b5 Show where each item starts in the inspiration and sync lists
Items in the inspiration list and the sync result columns now each have
their own background box, alternating between two shades, with a small
gap between them. When a long name wraps over several lines it is
still clear which lines belong together. The sync columns keep their
alternating white and orange text.
2026-10-06 08:32:56 +02:00
Sansan 2a332c2d19 Make the sync result look right with any number of columns
- Visible columns share the width equally, whether one or five are shown
- Long soundtrack names and errors wrap inside their column instead of
  widening the window, which pushed the title and Restart button out of
  view and cut off text
- Separators only go between columns; the extra one before the first
  column is gone
- Wider, centred panel with inner margins, and spacing above the
  Restart button
- The Re-added header no longer reads ReAdded
2026-10-06 08:32:56 +02:00
Sansan 6cbbfee6a1 Tidy up popup layouts
- Settings: labels in one column and switches and number buttons lined
  up in another, with margins inside the panel
- Log: title and margins; text no longer touches the panel edge
- Sync result: error list centred under its header, which now reads
  Errors instead of CatchedErrors
- About: text wraps inside the panel instead of running under the
  scrollbar
- Character select: wider panel, so the right column of characters is
  no longer right next to the scrollbar
2026-10-06 08:32:56 +02:00
Sansan c7d49bc5cc Rework popups and fix blurry text and player list layout
Popups:
- All nine popups now extend a shared PopupWindow class with open(),
  close() and toggle(), instead of each copying its own close-on-click
  code. Popups that need cleanup override close().
- A click outside a popup closed it and also pressed whatever was
  underneath, e.g. Reset playlist. The closing click is now consumed.
- Popups used Godot's see-through default panel, so the song list and
  titles showed through. They now use an opaque popup theme, and a
  backdrop dims the window behind them.
- The main window root now fills the screen, so the backdrop covers it.

Text and images:
- Many labels and buttons forced nearest filtering, which made text and
  icons jagged when the window is not 1920x1080. They now use the
  default smooth filtering. Only the pixel-art "no character" picture
  keeps nearest filtering when the winner window enlarges it.

Player list:
- A long player name pushed that row's character and buttons to the
  right. Names now take the free space and shorten with an ellipsis, so
  all rows line up and use the full width of the panel.
2026-10-06 08:32:56 +02:00
Sansan 6288739de0 Use the Compatibility renderer
The game is a 2D interface without 3D, shaders or lights, so it does
not need Forward+. With Compatibility it uses about 25% less CPU and
30 MB less memory while running, with no visible difference.
2026-10-06 08:32:56 +02:00
Sansan d79f5f92c5 Remove unused code
- The old character select scene, which was never shown but loaded
  every character image at startup
- Unused SyncResponse and ProgressResponse classes
- Playlist.set_last_played_answered and select/local-loading code in
  the new character select, none of which were called
- Settings.delete_player_children, identical to delete_children
- An unused variable holding a local file path
2026-10-06 08:32:56 +02:00
Sansan 14c166614d Add one log row at a time and pad log times
The log rebuilt every row each time a point was given or taken. It now
adds just the new row, and shows times as 09:05:03 instead of 9:5:3.
2026-10-06 08:32:56 +02:00
Sansan 428decf73a Turn statistics back on for a new game and fix low winning scores
Winning turned statistics off and nothing turned them back on, while
the Settings checkbox still showed them as on. Resetting points now
turns them back on if the win turned them off, and the checkbox shows
the current state when Settings is opened.

A winning score of 1 could never be won and with 2 the first point was
not announced as match point, because the first-point check ran first.
The winning score also could not be kept from going below 1.
2026-10-06 08:32:56 +02:00
Sansan 739d0f7af6 Preload songs only after the song list has loaded
At startup and after resetting the playlist, loading the song list and
preloading the first song started at the same time. When the list
answered last, it cleared the playlist and dropped the preloaded song.
Preloading now starts once the list has loaded, and loading the list is
retried if it fails.
2026-10-06 08:32:56 +02:00
Sansan ed292877ef Ignore Show answer when no song is playing
Before the first song played, currently_playing_song is -1, so pressing
Show answer marked the last song in the list as answered. That is the
preloaded song, so its title was shown as soon as it started playing,
and played?song=-1 was sent to the server. With an empty playlist it
crashed.
2026-10-06 08:32:56 +02:00
Sansan 5a979fca48 Time out requests the server never answers
HTTPRequest has no timeout by default, so a server that accepts the
connection but never responds left song downloads waiting forever.
Requests now fail after 120 seconds, which triggers the normal retry.
2026-10-06 08:32:56 +02:00
Sansan 1acc757981 Fill the song cache when the settings menu is closed
Raising the cache size used to have no effect until the next song was
played. Downloads now start when the settings window closes, so clicking
the number up and down while it is open does not start any downloads.
2026-10-06 08:32:56 +02:00
Sansan 16724922ed Test that the song cache fills up to the cache size setting
Covers a cache size above 1: songs are preloaded until the setting is
reached, playing a song tops the cache up again, and the cache keeps
filling after a failed download once the server answers again.
2026-10-06 08:32:56 +02:00
Sansan c1fc24adf9 Release from the version in project.godot
The version now lives in project.godot (application/config/version)
and Settings.version reads it from there. The Build workflow tests and
exports on every push, and on main creates the tag, uploads the builds
and creates the Gitea release when that version has no tag yet. This
replaces gitea-release.yaml, which released when a branch named after
the version was pushed.

Godot setup moves into a local .gitea/actions/setup-godot action shared
by the test and build jobs, and Godot is updated to 4.7.2 to match the
editor the project is made with.
2026-10-06 08:32:56 +02:00
Sansan f67dccbf52 Retry failed song downloads instead of getting stuck
MusicServerClient functions now take an optional on_failed callback,
called when a request fails or returns invalid JSON. Before, a failed
request was silently dropped, so a failure while preloading a song left
Playlist.is_song_downloading set forever and no more songs were fetched.

Song fetching retries the step that failed (download, queue or info)
every song_fetch_retry_seconds, so the server and the game keep the same
playlist.
2026-10-06 08:32:56 +02:00
Sansan c985a6ce5b Replace the broken test suite with working GdUnit4 tests
The previous tests never compiled: they used GUT's assert functions,
Godot 3 APIs and reassigned autoload functions, so no test ran.

- 90 tests: unit tests for the playlist, songs, player scoring, time
  formatting, search and the server client, plus integration tests
  that run MainWindow and SyncWindow against a fake server
- Keybind tests check that Alt+<number> and Ctrl+Alt+<number> keep
  following the player rows while players are added and removed
- run_tests.sh finds Godot and runs the suite headless (~10 seconds)
- CI runs the tests before the build job in gitea-build.yaml; the
  separate test.yml used a GitHub action that does not exist
- GdUnit4 is committed instead of ignored, since the project enables
  it, and .gitea/ is no longer ignored
- Exported builds leave out tests/ and addons/gdUnit4/
2026-10-06 08:32:56 +02:00
Sansan 56e9fe66d3 Move server calls to MusicServerClient and give players stable ids
Server requests move out of Settings into a new MusicServerClient
autoload with one function per endpoint. It sends requests through a
swappable HttpTransport so tests can fake the server, and it frees its
HTTPRequest nodes after use.

Players now have a UUID that never changes. Their position in
Settings.player_array is only used for the Alt+<number> keybinds.
Previously the position doubled as the id, so removing a player made
points and wins land on the wrong player, and could delete another
player's point from a song.

Also fixes:
- format_time showed 59.6 seconds as "00:60"
- searches containing regex characters such as "Q*bert" broke search
- SongObject and PlayerObject were Nodes that were never freed; they
  are now RefCounted
- search matching is split out of the UI code into find_matches()
2026-10-06 08:32:55 +02:00
Sansan ab338b0696 Add GdUnit4 testing framework with comprehensive test coverage
- Add GdUnit4 v6.2.1 framework in addons/gdUnit4/
- Create test directory structure (tests/unit/, tests/integration/, tests/mocks/)
- Add unit tests for MusicPlayer.gd (8 tests)
- Add unit tests for MainWindow.gd (15+ tests)
- Add unit tests for Settings.gd (5 tests)
- Add unit tests for Playlist.gd (11 tests)
- Add integration tests for full flow (10+ tests)
- Add MockHTTPRequest for HTTP request mocking
- Add TestHelpers with utility functions
- Add Gitea Actions CI workflow (.gitea/workflows/test.yml)
- Add .runsettings for GdUnit4 CLI configuration
- Add run_tests.sh for convenient test execution
- Add TESTING.md with comprehensive documentation
- Add TESTING_PLAN.md with implementation details
- Update .gitignore for test artifacts
- Enable GdUnit4 plugin in project.godot

Features:
- Test button functionality (play, pause, search, sync, etc.)
- Mock REST API requests to server
- Test complete application flows
- Run tests in CI (headless mode)
- Run tests in GUI for visual feedback
- JUnit XML and HTML test reports

Generated by OpenCode AI assistant.
2026-10-06 08:32:55 +02:00
Sansan 9a91d969bd Merge pull request 'Made improvements to the Search window, Made small changes to the inspiration list scrolling, Made small changes to the player list, Changed the term game to soundtrack, #28: Made changes to the sync window' (#37) from develop into main
Build / build (push) Successful in 1m6s
Reviewed-on: #37
2026-10-05 22:53:08 +02:00
396 changed files with 91516 additions and 5171 deletions

No files matched your search

-64
View File
@@ -1,64 +0,0 @@
extends PopupWindow
@onready
var about_panel: Panel = %AboutPanel
@onready
var version_label: Label = %VersionLabel
@onready
var whats_new_label: Label = %WhatsNewLabel
# Changelog as Dictionary with version as key and description as value
const WHATS_NEW: Dictionary = {
"1.9.0": "Made improvements to the Search window
Made small changes to the inspiration list scrolling
Made small changes to the player list
Changed the term game to soundtrack
#28: Made changes to the sync window
#34: Fixed bug in Character Select when clicking or scrolling",
"1.8.5": "#9: New winner screen with positions of all players
#32: New setting for how long to hide the beginning
#33: Fixed bug with hiding beginning when song restarts",
"1.8.0": "#14: Updated About page
#15: Create new keymap page
#20: The winner song can now be stopped
#24: The speed of the inspiration list can now be changed
#29: New settings to hide time left and things like that
#31: Added a QR page",
"1.7.0": "#22: Now block add player if player name is empty
#23: Add a log for when points are given to players
#25: Fixed some graphical stuff
#26: Changed so the same character can be on a song multiple times
#27: Turning off statistics after win",
"1.6.1": "#17: Removed Debug text in release
#18: Fixed bug with new character select screen",
"1.6.0": "#1: Fixed bug with inspiration list not reloading after sync
#2: New dialog for sync
#4: Now downloads characters from the server
#5: New character select screen
#6: Now show progress during sync
#7: Blocking all requests to the server during sync
#8: Added functionality to cache more than one song",
"1.5.0": "Made big changes to players and the song list and how the local song list works",
"0.9.0-Beta": "Fixed settings and updated the player view",
"0.8.0-Beta": "Fixed reset buttons and some other small things",
"0.7.8-Beta": "Added shortcuts. Added dialog for winner. Started cleaning code.",
"0.7.5-Beta": "Added settings menu, most things don't do anything yet",
"0.7-Beta": "Can now hop between songs"
}
func open() -> void:
super()
version_label.text = Settings.version
# Build changelog text with version headers
var changelog_text: String = ""
for version: String in WHATS_NEW.keys():
changelog_text += "\n" + version + "\n"
changelog_text += WHATS_NEW[version] + "\n"
whats_new_label.text = changelog_text
-61
View File
@@ -1,61 +0,0 @@
[gd_scene format=3 uid="uid://cp54idaudb8dp"]
[ext_resource type="Script" uid="uid://b7bh0of7olbcb" path="res://AboutWindow.gd" id="1_about_script"]
[ext_resource type="Theme" path="res://popup_theme.tres" id="popup_theme"]
[node name="AboutControl" type="Control" unique_id=1626010355 node_paths=PackedStringArray("panel")]
theme = ExtResource("popup_theme")
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_about_script")
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_bottom = 640.0
[node name="TitleLabel" type="Label" parent="AboutPanel" unique_id=200295721]
layout_mode = 0
offset_right = 600.0
offset_bottom = 50.0
text = "About"
horizontal_alignment = 1
vertical_alignment = 1
[node name="ScrollContainer" type="ScrollContainer" parent="AboutPanel" unique_id=417677913]
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
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]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
autowrap_mode = 2
-38
View File
@@ -1,38 +0,0 @@
extends ScrollContainer
# Scroll direction: 1 = down, -1 = up
var scroll_direction: int = 1
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
scroll_direction = 1
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
var speed_pixels_per_second: float = float(Settings.inspiration_list_speed) * 60.0
var scroll_amount: float = speed_pixels_per_second * delta * float(scroll_direction)
var scroll_bar = get_v_scroll_bar()
# Don't scroll if list is empty or too short
if scroll_bar.max_value <= 0:
return
var current_pos = scroll_bar.value
if scroll_direction == 1:
# Scrolling down
if current_pos + scroll_bar.page < scroll_bar.max_value:
scroll_bar.value = min(current_pos + scroll_amount, scroll_bar.max_value - scroll_bar.page)
else:
# Reached bottom, reverse direction
scroll_direction = -1
scroll_bar.value = scroll_bar.max_value - scroll_bar.page
else:
# Scrolling up
if current_pos > 0:
scroll_bar.value = max(current_pos + scroll_amount, 0)
else:
# Reached top, reverse direction
scroll_direction = 1
scroll_bar.value = 0
-7
View File
@@ -1,7 +0,0 @@
[gd_resource type="Theme" load_steps=2 format=3 uid="uid://rxexo3ur85as"]
[ext_resource type="StyleBox" uid="uid://blf3g88mugovr" path="res://neutral_gray.tres" id="1_alhp2"]
[resource]
Label/styles/normal = ExtResource("1_alhp2")
PanelContainer/styles/panel = ExtResource("1_alhp2")
-572
View File
@@ -1,572 +0,0 @@
extends Control
# 601 LOC 11/9 - 2023
# 628 LOC 15/9 - 2023
# 631 LOC 16/9 - 2023
# 554 LOC 30/5 - 2024
# 551 LOC 3/6 - 2024
# 386 LOC 12/6 - 2024
##TODO
# 15. Fix for local play
@onready
var open_button: Button = $Open
@onready
var fileDialog: FileDialog = $FileDialog
@onready
var inspiration_scroll: ScrollContainer = $ScrollContainer
@onready
var inspiration_list: VBoxContainer = $ScrollContainer/VBoxContainer
@onready
var soundtrack_label: Label = $VBoxContainer/SoundtrackLabel
@onready
var song_label: Label = $VBoxContainer/SongLabel
@onready
var add_player_container: HBoxContainer = $Players/VBoxContainer/AddPlayerContainer
@onready
var add_players_button: TextureButton = $Players/VBoxContainer/HBoxContainer/AddPlayersButton
@onready
var add_player_button: Button = $Players/VBoxContainer/AddPlayerContainer/AddPlayerButton
@onready
var new_player_name_field: TextEdit = $Players/VBoxContainer/AddPlayerContainer/PlayerNameField
@onready
var reset_playlist_button: Button = $ResetPlaylistButton
@onready
var reset_points_button: Button = $ResetPointsButton
@onready
var sound_test_button: Button = $SoundTestButton
@onready
var sync_button: Button = $SyncButton
@onready
var sync_window: Control = %SyncWindow
@onready
var settings_button: Button = $SettingsButton
@onready
var settings_window: Control = $SettingsWindow
@onready
var shortcuts_window: Control = %ShortcutsWindow
@onready
var qr_window: Control = %QRWindow
@onready
var winner_window: Control = %WinnerWindow
@onready
var qr_button: Button = $QRButton
@onready
var statistics_button: Button = $StatisticsButton
@onready
var statistic_popup: PopupPanel = $StatisticsPopupPanel
@onready
var statistic_label: Label = $StatisticsPopupPanel/StatisticsLabel
@onready
var shortcuts_button: Button = $ShortcutsButton
@onready
var about_button: Button = $AboutButton
@onready
var about_window: Control = %AboutWindow
@onready
var show_answer_button: Button = $ShowAnswerButton
@onready
var next_button: Button = $NextButton
@onready
var music_list_scroll: ScrollContainer = $MusicListPanel/ScrollContainer
@onready
var music_list: VBoxContainer = $MusicListPanel/ScrollContainer/MusicList
@onready
var player_container: VBoxContainer = $Players/VBoxContainer
@onready
var players_list_container: VBoxContainer = $Players/VBoxContainer/ScrollContainer/PlayersListVBoxContainer
@onready
var new_character_select: Control = %NewCharacterSelect
@onready
var search_button: Button = $SearchButton
@onready
var search_view: Control= $Search
@onready
var auto_repeat_song_button: CheckButton = $RepeatSongCheckButton
@onready
var music_player_container: PanelContainer = $MusicPlayer
@onready
var log_window: Control = %Log
@onready
var debug_label: Label = $DebugLabel
var player: PackedScene = preload("res://Player.tscn")
var song_list_object: PackedScene = preload("res://song_list_object.tscn")
var songs: Array= []
var soundtracks: Array = []
var current_player: Node
## True while statistics are off because a player won, until points are reset.
var stats_paused_by_win: bool = false
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
var popup_backdrop: ColorRect
## Seconds to wait before retrying a song download step that failed.
var song_fetch_retry_seconds: float = 5.0
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
_setup_popup_backdrop()
var debug: bool = OS.has_feature("debug")
var is_mac: bool = OS.has_feature("macos")
if debug:
print("is_debug")
debug_label.visible = true
Settings.is_debug = true
settings_window.set_winning_score(5)
settings_window.set_selected_server(4)
if is_mac:
print("is_mac")
next_button.pressed.connect(play_next_song)
show_answer_button.pressed.connect(show_answer_pressed)
search_button.pressed.connect(show_search)
sync_button.pressed.connect(sync_soundtracks)
sound_test_button.pressed.connect(music_player_container.get_sound_test_song)
statistics_button.pressed.connect(get_statistics)
shortcuts_button.pressed.connect(show_shortcuts)
about_button.pressed.connect(show_about)
settings_button.pressed.connect(show_settings)
qr_button.pressed.connect(show_qr)
reset_playlist_button.pressed.connect(reset_playlist)
reset_points_button.pressed.connect(reset_points)
new_character_select.connect("new_new_character_selected", _on_new_character_selected)
new_player_name_field.connect("enter_key_pressed", add_player)
music_player_container.connect("play_next_song", play_next_song)
music_player_container.connect("update_song_list", update_song_list)
settings_window.connect("server_changed", server_updated)
settings_window.visibility_changed.connect(_on_settings_window_visibility_changed)
sync_window.connect("sync_started", _on_sync_started)
sync_window.connect("sync_finished", _on_sync_finished)
add_players_button.pressed.connect(add_players)
add_player_button.pressed.connect(add_player)
open_button.pressed.connect(open)
auto_repeat_song_button.pressed.connect(repeat_song)
get_suggestion_list()
fetch_song_list_at_start()
search_view.get_list_of_soundtracks()
new_character_select.load_characters()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed:
if event.alt_pressed && event.keycode == KEY_UP:
print("Alt + UP pressed")
music_player_container.change_volume(0.05)
if event.alt_pressed && event.keycode == KEY_DOWN:
print("Alt + DOWN pressed")
music_player_container.change_volume(-0.05)
if event.alt_pressed && event.ctrl_pressed && event.keycode == KEY_LEFT:
print("Ctrl + Alt + LEFT pressed")
music_player_container.restart()
elif event.alt_pressed && event.keycode == KEY_LEFT:
print("Alt + LEFT pressed")
music_player_container.seek(-5)
if event.alt_pressed && event.keycode == KEY_RIGHT:
print("Alt + RIGHT pressed")
music_player_container.seek(5)
if event.alt_pressed && event.keycode == KEY_ENTER:
print("Alt + Enter pressed")
Settings.fullscreen = !Settings.fullscreen
settings_window.fullscreen_button.button_pressed = Settings.fullscreen
if Settings.fullscreen == true:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
if Settings.fullscreen == false:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
if event.alt_pressed && event.keycode == KEY_K:
shortcuts_window.toggle()
if event.alt_pressed && event.keycode == KEY_L:
log_window.toggle()
func server_updated() -> void:
print("server_updated")
update_from_server()
reset_points()
func update_from_server() -> void:
print("update_from_server")
search_view.get_list_of_soundtracks()
get_suggestion_list()
reset_playlist()
new_character_select.load_characters()
func repeat_song() -> void:
Settings.auto_repeat_song = !Settings.auto_repeat_song
func show_answer_pressed() -> void:
print("show_answer_pressed")
Playlist.song_is_answered()
update_song_list()
# Loads the songs already in the server playlist, then starts preloading new ones.
# Preloading waits for the list, because loading the list clears the playlist.
func fetch_song_list_at_start() -> void:
print("fetch_song_list_at_start")
var fetch_song_list_at_start_done: Callable = func fetch_songs_at_start_done(data) -> void:
Playlist.clear_song_list()
if typeof(data) == TYPE_ARRAY:
for d: Dictionary in data:
var new_song_object: SongObject = SongObject.new()
new_song_object.song_number = d.SongNo
new_song_object.soundtrack_title = d.Soundtrack
new_song_object.song_title = d.Song
new_song_object.is_answered = true
new_song_object.has_played = true
Playlist.add_to_playlist(new_song_object)
elif data != null:
print("Unexpected data")
Playlist.update_last_played_song()
update_song_list()
if !Playlist.is_song_downloading:
fetch_song()
MusicServerClient.fetch_song_list(fetch_song_list_at_start_done,
_retry_later.bind(fetch_song_list_at_start))
func play_next_song() -> void:
print("play_next_song ", Playlist.latest_played_song)
if Playlist.first_song_played:
Playlist.set_latest_played_song()
Playlist.first_song_played = true
music_player_container.play_song_object(Playlist.latest_played_song)
if !(Playlist.get_preloaded_songs() > 0):
next_button.disabled = true
if !Playlist.is_song_downloading:
fetch_song()
#not first "/music/addPlayed"
#"/music/rand"
#"/music/addQue"
#"/music/info"
func _popups() -> Array[PopupWindow]:
return [search_view, sync_window, settings_window, qr_window, new_character_select,
shortcuts_window, about_window, winner_window, log_window]
# The backdrop sits above the main window and below every popup. Popups close
# themselves when clicked outside, and the backdrop keeps that click from reaching
# the buttons behind them.
func _setup_popup_backdrop() -> void:
popup_backdrop = ColorRect.new()
popup_backdrop.name = "PopupBackdrop"
popup_backdrop.color = Color(0, 0, 0, 0.55)
popup_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(popup_backdrop)
popup_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
for popup: PopupWindow in _popups():
move_child(popup, -1)
popup.visibility_changed.connect(_update_popup_backdrop)
_update_popup_backdrop()
func _update_popup_backdrop() -> void:
popup_backdrop.visible = _popups().any(func(popup: PopupWindow) -> bool: return popup.visible)
# The cache size can be changed in the settings. Waiting until the settings are closed
# avoids starting downloads while the user is still clicking the number up and down.
func _on_settings_window_visibility_changed() -> void:
if !settings_window.visible && !Playlist.is_song_downloading && Playlist.should_preload_more_songs():
fetch_song()
func fetch_song() -> void:
print("fetch_song")
Playlist.is_song_downloading = true
_download_random_song(SongObject.new())
# Fetching a song takes three requests. If one fails, only that step is retried, so
# the server and the game keep the same songs in their playlists.
func _download_random_song(new_song_object: SongObject) -> void:
var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void:
print("song_downloaded")
var sound: AudioStream = AudioStreamMP3.new()
sound.data = body
new_song_object.song = sound
_add_song_to_queue(new_song_object)
MusicServerClient.fetch_random_song(Settings.use_low_played_mode, song_downloaded,
_retry_later.bind(_download_random_song.bind(new_song_object)))
func _add_song_to_queue(new_song_object: SongObject) -> void:
MusicServerClient.add_to_queue(_fetch_song_info.bind(new_song_object),
_retry_later.bind(_add_song_to_queue.bind(new_song_object)))
func _fetch_song_info(new_song_object: SongObject) -> void:
var song_info_fetched: Callable = func song_info_fetched(data_received: Dictionary) -> void:
print("song_info_fetched")
new_song_object.song_number = data_received.SongNo
new_song_object.soundtrack_title = data_received.Soundtrack
new_song_object.song_title = data_received.Song
print("new_song_object: ", new_song_object.to_string())
Playlist.add_to_playlist(new_song_object)
next_button.disabled = false
if Playlist.should_preload_more_songs():
fetch_song()
else:
Playlist.is_song_downloading = false
if Settings.is_debug:
update_song_list()
MusicServerClient.fetch_song_info(song_info_fetched,
_retry_later.bind(_fetch_song_info.bind(new_song_object)))
func _retry_later(step: Callable) -> void:
print("Song download failed, retrying in %s seconds" % song_fetch_retry_seconds)
get_tree().create_timer(song_fetch_retry_seconds).timeout.connect(step)
func update_song_list() -> void:
print("update_song_list")
if Playlist.currently_playing_song >= 0:
soundtrack_label.text = Playlist.get_current_soundtrack_title()
song_label.text = Playlist.get_current_song_title()
Settings.delete_children(music_list)
for s: SongObject in Playlist.get_playlist():
var new_song_list_object: SongListObject = song_list_object.instantiate()
new_song_list_object.song_object = s
new_song_list_object.gui_input.connect(song_object_clicked.bind(s.song_number))
music_list.add_child(new_song_list_object)
func song_object_clicked(event: InputEvent, song_no: int) -> void:
if (event is InputEventMouseButton && event.pressed && event.button_index == MOUSE_BUTTON_LEFT):
print("Clicked Song Object: ", Playlist.song_object_array[song_no].to_string())
if Settings.character_select_open == false:
if Playlist.get_song(song_no) == null:
print("has no song, need to download")
var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void:
var sound: AudioStream = AudioStreamMP3.new()
sound.data = body
Playlist.add_song_to_song_object(song_no, sound)
music_player_container.play_song_object(song_no)
MusicServerClient.fetch_song(song_no, song_downloaded)
else:
print("song already downloaded")
music_player_container.play_song_object(song_no)
func reset_playlist() -> void:
print("reset_playlist")
var playlist_reset_done: Callable = func playlist_reset_done() -> void:
Playlist.reset_playlist()
fetch_song_list_at_start()
MusicServerClient.reset_playlist(playlist_reset_done)
func reset_points() -> void:
print("reset_points")
for player_objects: PlayerObject in Settings.player_array:
player_objects.reset_points()
load_players()
# A new game starts, so turn statistics back on if winning turned them off
if stats_paused_by_win:
Settings.add_to_stats = true
stats_paused_by_win = false
func show_about() -> void:
print("show_about")
about_window.open()
func show_shortcuts() -> void:
print("show_shortcuts")
shortcuts_window.open()
func show_settings() -> void:
print("show_settings")
settings_window.open()
func show_qr() -> void:
print("show_qr")
qr_window.open()
func get_statistics() -> void:
print("get_statistics")
statistic_popup.visible = true
statistic_label.text = "Total amount of soundtracks in the playlist: " + str(soundtracks.size())
func sync_soundtracks() -> void:
print("sync_soundtracks")
sync_window.start_sync()
func _on_sync_started() -> void:
print("_on_sync_started")
sync_button.disabled = false
sound_test_button.disabled = true
reset_playlist_button.disabled = true
show_answer_button.disabled = true
next_button.disabled = true
func _on_sync_finished() -> void:
print("_on_sync_finished")
update_from_server()
sound_test_button.disabled = false
reset_playlist_button.disabled = false
show_answer_button.disabled = false
reset_points()
func get_suggestion_list() -> void:
print("get_suggestion_list")
var populate_list: Callable = func populate_list(array) -> void:
if typeof(array) == TYPE_ARRAY:
soundtracks = []
Settings.delete_children(inspiration_list)
soundtracks.append_array(array)
for i: int in soundtracks.size():
inspiration_list.add_child(ListRow.create(soundtracks[i], i, 20))
else:
print("Unexpected data")
MusicServerClient.fetch_all_soundtracks(populate_list)
func add_players() -> void:
print("add_players")
Settings.edit_players = !Settings.edit_players
add_player_container.visible = !add_player_container.visible
new_player_name_field.grab_focus()
func add_player() -> void:
print("add_player")
if new_player_name_field.text != "" && Settings.player_array.size() < Settings.MAX_NUMBER_OF_PLAYERS:
var new_player_object: PlayerObject = PlayerObject.new(new_player_name_field.text)
new_player_name_field.text = ""
Settings.player_array.append(new_player_object)
new_player_object.connect("first_point_triggerd", music_player_container._on_point_triggered.bind("first"))
new_player_object.connect("match_point_triggerd", music_player_container._on_point_triggered.bind("match"))
new_player_object.connect("winner_triggerd", _on_player_won.bind(new_player_object))
new_player_object.connect("point_given_sound", _on_make_point_given_sound)
new_player_object.connect("player_point_given", _on_point_given.bind(new_player_object))
new_player_object.connect("player_point_taken", _on_point_taken.bind(new_player_object))
load_players()
func load_players() -> void:
print("load_players")
Settings.delete_children(players_list_container)
var counter: int = 0
for player_object: PlayerObject in Settings.player_array:
print(player_object.player_name)
var new_player: Player = player.instantiate()
new_player.player_object = player_object
new_player.list_index = counter
new_player.connect("change_character_clicked", _on_player_change_character_clicked.bind(new_player, player_object.player_name))
new_player.connect("player_removed", _on_player_removed.bind(player_object))
players_list_container.add_child(new_player)
counter += 1
func _on_point_given(player_object: PlayerObject) -> void:
print("_on_point_given")
log_window.add_log_row(player_object.player_name + " got a point")
if Playlist.currently_playing_song >= 0:
Playlist.add_point(player_object.id)
update_song_list()
func _on_make_point_given_sound() -> void:
music_player_container.play_sound_effect(preload("res://sounds/itemopen.wav"))
func _on_point_taken(player_object: PlayerObject) -> void:
print("_on_point_taken")
log_window.add_log_row(player_object.player_name + " lost a point")
music_player_container.play_sound_effect(preload("res://sounds/itemequip.wav"))
if Playlist.currently_playing_song >= 0:
Playlist.remove_point(player_object.id)
update_song_list()
func _on_player_won(player_object: PlayerObject) -> void:
print("_on_player_won")
winner_window.show_winner(player_object)
music_player_container.play_song(preload("res://sounds/winning.mp3"))
# Songs played after the game is won don't count towards the statistics
if Settings.add_to_stats:
Settings.add_to_stats = false
stats_paused_by_win = true
func _on_player_removed(player_object: PlayerObject) -> void:
print("_on_player_removed ", player_object.player_name)
Settings.player_array.erase(player_object)
load_players()
Playlist.remove_player_from_list(player_object.id)
update_song_list()
func _on_player_change_character_clicked(new_player: Player, player_name: String) -> void:
print("_on_player_change_character_clicked")
current_player = new_player
print("Change character for: " + player_name)
new_character_select.show_grid(player_name)
func _on_new_character_selected(character_texture: ImageTexture) -> void:
print("_on_new_character_selected")
current_player.new_character_selected(character_texture)
func show_search() -> void:
print("show_search")
search_view.toggle()
###Local
var local_path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3'
func open() -> void:
fileDialog.popup()
func _on_file_dialog_dir_selected(path: String) -> void:
print(path)
dir_contents(path)
func dir_contents(path: String) -> void:
var dir: DirAccess = DirAccess.open(path)
if dir:
dir.list_dir_begin()
var file_name: String = dir.get_next()
songs.clear()
soundtracks = []
while file_name != "":
if dir.current_is_dir():
#print("Found directory: " + file_name)
soundtracks.append(file_name)
else:
#print("Found file: " + file_name)
if file_name.ends_with(".mp3"):
songs.append(path + "/" + file_name)
file_name = dir.get_next()
else:
print("An error occurred when trying to access the path.")
for i: int in soundtracks.size():
inspiration_list.add_child(ListRow.create(soundtracks[i], i, 20))
-502
View File
@@ -1,502 +0,0 @@
[gd_scene format=3 uid="uid://xwq863o6uvsu"]
[ext_resource type="Script" uid="uid://b8f4g15cas2j2" path="res://MainWindow.gd" id="1_eu0t5"]
[ext_resource type="Script" uid="uid://elgn7gkxgagq" path="res://InsperationScrollContainer.gd" id="2_gxtxm"]
[ext_resource type="Texture2D" uid="uid://bcfmpd7h512ef" path="res://icons/person_add_light.svg" id="5_31tjv"]
[ext_resource type="PackedScene" uid="uid://ds15cgsf8vpvc" path="res://MusicPlayer.tscn" id="5_emn36"]
[ext_resource type="PackedScene" uid="uid://cdy4kvemwaiom" path="res://new_character_select.tscn" id="6_dhrvg"]
[ext_resource type="Script" uid="uid://gf1bk6xbe3j" path="res://MusicListScrollContainer.gd" id="7_dj026"]
[ext_resource type="Script" uid="uid://cgakje8gb37tf" path="res://PlayerNameField.gd" id="7_qsdfy"]
[ext_resource type="Theme" uid="uid://rxexo3ur85as" path="res://LightGrayTheme.tres" id="7_wxbv6"]
[ext_resource type="PackedScene" uid="uid://bxydgil1yifps" path="res://SearchWindow.tscn" id="9_5ijvr"]
[ext_resource type="PackedScene" uid="uid://dpdem7pdxweb5" path="res://SyncWindow.tscn" id="10_yxw0b"]
[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://SettingsWindow.tscn" id="11_k62u5"]
[ext_resource type="PackedScene" uid="uid://bd1by80q1v27e" path="res://shortcutsWindow.tscn" id="13_8eebo"]
[ext_resource type="PackedScene" uid="uid://bijh5h5yrivm3" path="res://Log.tscn" id="14_26rwn"]
[ext_resource type="PackedScene" uid="uid://btupbowehiyyu" path="res://QRWindow.tscn" id="15_qr_window"]
[ext_resource type="PackedScene" uid="uid://cp54idaudb8dp" path="res://AboutWindow.tscn" id="16_about_window"]
[ext_resource type="PackedScene" uid="uid://cxlsmxnrp0agh" path="res://WinnerWindow.tscn" id="17_winner_window"]
[ext_resource type="Theme" path="res://popup_theme.tres" id="popup_theme"]
[sub_resource type="LabelSettings" id="LabelSettings_ychxr"]
font_size = 25
[sub_resource type="LabelSettings" id="LabelSettings_qspbu"]
font_size = 50
[sub_resource type="LabelSettings" id="LabelSettings_3m52w"]
font_size = 35
[sub_resource type="InputEventKey" id="InputEventKey_03bm3"]
device = -1
alt_pressed = true
keycode = 65
unicode = 63743
[sub_resource type="Shortcut" id="Shortcut_jafqj"]
events = [SubResource("InputEventKey_03bm3")]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_qk7sj"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_k1ygi"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="InputEventKey" id="InputEventKey_06rl4"]
device = -1
alt_pressed = true
keycode = 86
unicode = 8249
[sub_resource type="Shortcut" id="Shortcut_a7fvb"]
events = [SubResource("InputEventKey_06rl4")]
[sub_resource type="InputEventKey" id="InputEventKey_7dnqw"]
device = -1
alt_pressed = true
keycode = 67
unicode = 231
[sub_resource type="Shortcut" id="Shortcut_d6fml"]
events = [SubResource("InputEventKey_7dnqw")]
[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")]
[node name="Control" type="Control" unique_id=407149696]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_eu0t5")
[node name="TopPanelContainer" type="PanelContainer" parent="." unique_id=663404711]
layout_mode = 0
offset_right = 1920.0
offset_bottom = 56.0
[node name="Title" type="Label" parent="." unique_id=1187907861]
layout_mode = 0
offset_left = 64.0
offset_top = 12.0
offset_right = 369.0
offset_bottom = 50.0
size_flags_horizontal = 0
text = "Music Player Randomizer"
label_settings = SubResource("LabelSettings_ychxr")
[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" type="PanelContainer" parent="." unique_id=2011292375]
layout_mode = 0
offset_left = 64.0
offset_top = 384.0
offset_right = 1152.0
offset_bottom = 896.0
theme = ExtResource("7_wxbv6")
[node name="ScrollContainer" type="ScrollContainer" parent="MusicListPanel" unique_id=707750459]
layout_mode = 2
follow_focus = true
script = ExtResource("7_dj026")
[node name="MusicList" type="VBoxContainer" parent="MusicListPanel/ScrollContainer" unique_id=373398568]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 10
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=498283761]
layout_mode = 0
offset_left = 1216.0
offset_top = 128.0
offset_right = 1857.0
offset_bottom = 512.0
horizontal_scroll_mode = 0
vertical_scroll_mode = 3
script = ExtResource("2_gxtxm")
[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer" unique_id=1215162949]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=42468498]
layout_mode = 0
offset_left = 64.0
offset_top = 64.0
offset_right = 1152.0
offset_bottom = 384.0
[node name="SoundtrackLabel" type="Label" parent="VBoxContainer" unique_id=173147793]
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
label_settings = SubResource("LabelSettings_qspbu")
horizontal_alignment = 1
autowrap_mode = 2
[node name="SongLabel" type="Label" parent="VBoxContainer" unique_id=1687490027]
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
label_settings = SubResource("LabelSettings_3m52w")
horizontal_alignment = 1
autowrap_mode = 2
[node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")]
layout_mode = 0
anchors_preset = 0
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 0.0
anchor_bottom = 0.0
offset_left = 65.0
offset_top = 992.0
offset_right = 1856.0
offset_bottom = 1024.0
grow_horizontal = 1
grow_vertical = 1
[node name="Players" type="PanelContainer" parent="." unique_id=1708375945]
layout_mode = 0
offset_left = 1216.0
offset_top = 576.0
offset_right = 1857.0
offset_bottom = 977.0
[node name="VBoxContainer" type="VBoxContainer" parent="Players" unique_id=270084211]
layout_mode = 2
[node name="HBoxContainer" type="HBoxContainer" parent="Players/VBoxContainer" unique_id=513047023]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
[node name="Label" type="Label" parent="Players/VBoxContainer/HBoxContainer" unique_id=1331357386]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 1
text = "Players"
horizontal_alignment = 1
vertical_alignment = 1
[node name="AddPlayersButton" type="TextureButton" parent="Players/VBoxContainer/HBoxContainer" unique_id=634410148]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
size_flags_horizontal = 8
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_jafqj")
texture_normal = ExtResource("5_31tjv")
[node name="AddPlayerContainer" type="HBoxContainer" parent="Players/VBoxContainer" unique_id=1371948739]
visible = false
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
[node name="Panel" type="Panel" parent="Players/VBoxContainer/AddPlayerContainer" unique_id=1569854056]
layout_mode = 2
size_flags_horizontal = 3
theme_override_styles/panel = SubResource("StyleBoxFlat_qk7sj")
[node name="PlayerNameField" type="TextEdit" parent="Players/VBoxContainer/AddPlayerContainer" unique_id=332535327]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Player name"
script = ExtResource("7_qsdfy")
[node name="AddPlayerButton" type="Button" parent="Players/VBoxContainer/AddPlayerContainer" unique_id=245001313]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
text = "Add"
[node name="Panel2" type="Panel" parent="Players/VBoxContainer/AddPlayerContainer" unique_id=60215448]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
size_flags_horizontal = 3
theme_override_styles/panel = SubResource("StyleBoxFlat_k1ygi")
[node name="ScrollContainer" type="ScrollContainer" parent="Players/VBoxContainer" unique_id=1000000001]
layout_mode = 2
size_flags_vertical = 15
horizontal_scroll_mode = 0
[node name="PlayersListVBoxContainer" type="VBoxContainer" parent="Players/VBoxContainer/ScrollContainer" unique_id=1000000002]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 15
[node name="ResetPlaylistButton" type="Button" parent="." unique_id=1754155620]
layout_mode = 0
offset_left = 1360.0
offset_top = 64.0
offset_right = 1471.0
offset_bottom = 95.0
focus_mode = 0
action_mode = 0
text = "Reset playlist"
[node name="ResetPointsButton" type="Button" parent="." unique_id=942880776]
layout_mode = 0
offset_left = 1488.0
offset_top = 64.0
offset_right = 1599.0
offset_bottom = 95.0
focus_mode = 0
action_mode = 0
text = "Reset points"
[node name="SyncButton" type="Button" parent="." unique_id=1228313556]
layout_mode = 0
offset_left = 1616.0
offset_top = 64.0
offset_right = 1759.0
offset_bottom = 95.0
focus_mode = 0
action_mode = 0
text = "Sync soundtracks"
[node name="SoundTestButton" type="Button" parent="." unique_id=1332971879]
layout_mode = 0
offset_left = 1776.0
offset_top = 64.0
offset_right = 1887.0
offset_bottom = 95.0
focus_mode = 0
action_mode = 0
text = "Sound test"
[node name="ShowAnswerButton" type="Button" parent="." unique_id=415388039]
layout_mode = 0
offset_left = 65.0
offset_top = 944.0
offset_right = 176.0
offset_bottom = 975.0
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_a7fvb")
text = "Show answer"
[node name="NextButton" type="Button" parent="." unique_id=1744703488]
layout_mode = 0
offset_left = 193.0
offset_top = 944.0
offset_right = 368.0
offset_bottom = 975.0
focus_mode = 0
disabled = true
action_mode = 0
shortcut = SubResource("Shortcut_d6fml")
text = "Randomize new track"
[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
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_fbju4")
text = "Search"
[node name="SettingsButton" type="Button" parent="." unique_id=1193400323]
layout_mode = 0
offset_left = 1442.0
offset_top = 8.0
offset_right = 1512.0
offset_bottom = 48.0
focus_mode = 0
action_mode = 0
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
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
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
focus_mode = 0
action_mode = 0
text = "QR"
[node name="Search" 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
[node name="SyncWindow" 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_right = 210.0
offset_bottom = 153.0
[node name="SettingsWindow" 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
[node name="QRWindow" 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
[node name="NewCharacterSelect" 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
[node name="ShortcutsWindow" 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]
theme = ExtResource("popup_theme")
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]
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
[node name="AboutWindow" 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
[node name="WinnerWindow" 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
[node name="RepeatSongCheckButton" type="CheckButton" parent="." unique_id=776835053]
layout_mode = 0
offset_left = 64.0
offset_top = 1032.0
offset_right = 208.0
offset_bottom = 1063.0
focus_mode = 0
button_pressed = true
action_mode = 0
text = "Auto repeat"
[node name="DebugLabel" type="Label" parent="." unique_id=1959221257]
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
[node name="Log" 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
[connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"]
-8
View File
@@ -1,8 +0,0 @@
extends ScrollContainer
var max_value = 0;
func _draw():
if max_value != self.get_v_scroll_bar().max_value:
max_value = self.get_v_scroll_bar().max_value
self.scroll_vertical = max_value
-228
View File
@@ -1,228 +0,0 @@
class_name MusicPlayer
extends PanelContainer
# LOC 20/1-24: 136
# LOC 12/6-24: 170
@onready
var play_button: TextureButton = $MusicPlayerContainer/PlayTextureButton
@onready
var restart_button: TextureButton = $MusicPlayerContainer/RestartTextureButton
@onready
var audio_player: AudioStreamPlayer = $AudioStreamPlayer
@onready
var sound_player: AudioStreamPlayer = $AudioSoundPlayer
@onready
var sound_effect_player: AudioStreamPlayer = $AudioSoundEffectPlayer
@onready
var progress_slider: HSlider = $MusicPlayerContainer/MusicPlayerSlider
@onready
var volume_slider: HSlider = $MusicPlayerContainer/VolumeSlider
@onready
var music_time_label: Label = $MusicPlayerContainer/MusicTimeLabel
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 is_changing: bool = false
var playback_position: float
var stream: AudioStream
var song_finished: bool = false
signal update_song_list
signal play_next_song
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
play_button.pressed.connect(play_or_pause)
restart_button.pressed.connect(restart)
progress_slider.drag_started.connect(_on_drag_started)
progress_slider.drag_ended.connect(_on_drag_ended)
audio_player.finished.connect(_on_finished)
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)
func format_time(time: float) -> String:
var total_seconds: int = roundi(time)
@warning_ignore("integer_division")
return "%02d:%02d" % [total_seconds / 60, total_seconds % 60]
func format_text(part: float, total: float) -> String:
if (Settings.hide_beginning && part < Settings.hide_for_seconds) && Settings.hide_length:
return "??:?? / ??:??"
elif Settings.hide_length:
return format_time(part) + " / ??:??"
elif Settings.hide_beginning && part < Settings.hide_for_seconds:
return "??:?? / " + format_time(total)
else:
return format_time(part) + " / " + format_time(total)
func play_or_pause() -> void:
if song_finished:
restart()
elif audio_player.stream_paused:
play_button.texture_normal = pause_icon
audio_player.stream_paused = false
song_finished = false
audio_player.seek(playback_position)
print("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)
else:
pause()
func pause() -> void:
audio_player.stream_paused = true
playback_position = audio_player.get_playback_position()
play_button.texture_normal = play_icon
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()
func _on_finished() -> void:
play_button.texture_normal = play_icon
song_finished = true
if !Settings.stop_after_current:
play_next_song.emit()
if Settings.auto_repeat_song:
restart()
func _on_drag_started() -> void:
is_changing = true
func _on_drag_ended(_changed: bool) -> void:
audio_player.seek(progress_slider.value)
playback_position = progress_slider.value
is_changing = false
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
is_changing = false
func change_volume(value: float) -> void:
volume_slider.value += value
volume_slider.change_volume(volume_slider.value)
func _on_point_triggered(point: String) -> void:
if point == "first":
var value: int = randi_range(0, 10)
if value == 0:
play_sound(preload("res://sounds/sound1.mp3"))
elif value < 5:
play_sound(preload("res://sounds/intro_long.mp3"))
else:
play_sound(preload("res://sounds/intro_short.mp3"))
elif point == "match":
play_sound(preload("res://sounds/sound0.mp3"))
func play_sound_effect(sound_effect_name: AudioStream) -> void:
sound_effect_player.stream = sound_effect_name
sound_effect_player.play()
func play_sound(sound_name: AudioStream) -> void:
sound_player.stream = sound_name
audio_player.stop()
sound_player.play()
song_finished = true
play_button.texture_normal = play_icon
progress_slider.value = 0
#if stream != null:
# music_time_label.text = format_text(progress_slider.value, stream.get_length())
func play_song(song: AudioStream) -> void:
audio_player.stream = song
audio_player.play()
sound_player.stop()
song_finished = false
play_button.texture_normal = pause_icon
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())
func play_song_object(song_object_no: int) -> void:
print("play_song_object")
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)
sound_player.stop()
audio_player.play()
song_finished = false
play_button.texture_normal = pause_icon
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())
Playlist.set_currently_playing_song(song_object_no)
Playlist.unset_is_playing()
if !Settings.hide_next_track:
print("Show answer now!!")
Playlist.song_is_answered(song_object_no)
Playlist.song_has_played(song_object_no)
Playlist.song_is_playing(song_object_no)
update_song_list.emit()
func get_sound_test_song() -> void:
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]
print(local_path)
print(FileAccess.file_exists(local_path))
play_sound(load_mp3(local_path))
func sound_test_local() -> void:
play_sound(preload("res://sounds/01. Opening.mp3"))
-94
View File
@@ -1,94 +0,0 @@
[gd_scene load_steps=10 format=3 uid="uid://ds15cgsf8vpvc"]
[ext_resource type="Script" uid="uid://d1v0rquma6nt8" path="res://MusicPlayer.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="PackedScene" uid="uid://w400rnew7453" path="res://volume_slider.tscn" id="5_iifuj"]
[sub_resource type="InputEventKey" id="InputEventKey_r4qo3"]
device = -1
alt_pressed = true
keycode = 88
physical_keycode = 88
key_label = 88
unicode = 120
[sub_resource type="Shortcut" id="Shortcut_ipcfh"]
events = [SubResource("InputEventKey_r4qo3")]
[sub_resource type="InputEventKey" id="InputEventKey_2nvce"]
device = -1
alt_pressed = true
keycode = 90
unicode = 90
[sub_resource type="Shortcut" id="Shortcut_i7swq"]
events = [SubResource("InputEventKey_2nvce")]
[node name="MusicPlayer" type="PanelContainer"]
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -121.0
offset_top = -15.5
offset_right = 121.0
offset_bottom = 16.5
grow_horizontal = 2
grow_vertical = 2
size_flags_vertical = 4
script = ExtResource("1_t24ra")
[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="."]
stream = ExtResource("2_xti80")
bus = &"music"
[node name="AudioSoundPlayer" type="AudioStreamPlayer" parent="."]
stream = ExtResource("2_xti80")
bus = &"music"
[node name="AudioSoundEffectPlayer" type="AudioStreamPlayer" parent="."]
stream = ExtResource("2_xti80")
bus = &"music"
[node name="MusicPlayerContainer" type="HBoxContainer" parent="."]
layout_mode = 2
size_flags_vertical = 4
[node name="PlayTextureButton" type="TextureButton" parent="MusicPlayerContainer"]
custom_minimum_size = Vector2(32, 32)
layout_mode = 2
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_ipcfh")
texture_normal = ExtResource("3_6g308")
ignore_texture_size = true
stretch_mode = 0
[node name="RestartTextureButton" type="TextureButton" parent="MusicPlayerContainer"]
custom_minimum_size = Vector2(32, 32)
layout_mode = 2
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_i7swq")
texture_normal = ExtResource("4_jleuo")
ignore_texture_size = true
stretch_mode = 0
flip_h = true
[node name="MusicPlayerSlider" type="HSlider" parent="MusicPlayerContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
scrollable = false
[node name="MusicTimeLabel" type="Label" parent="MusicPlayerContainer"]
layout_mode = 2
text = "0:00 / 0:00"
[node name="VolumeSlider" parent="MusicPlayerContainer" instance=ExtResource("5_iifuj")]
custom_minimum_size = Vector2(100, 0)
layout_mode = 2
size_flags_vertical = 4
-78
View File
@@ -1,78 +0,0 @@
class_name Player
extends Control
@onready
var player_name_field := $HBoxContainer/Name
@onready
var add_button := $HBoxContainer/AddPoint
@onready
var minus_button := $HBoxContainer/RemovePoint
@onready
var character := $HBoxContainer/Character
@onready
var remove_player := $HBoxContainer/RemovePlayer
signal change_character_clicked
signal player_removed
## The player shown in this row.
var player_object: PlayerObject
## Position in Settings.player_array. Only used for the keybinds: Alt+<list_index + 1>
## gives this player a point and Ctrl+Alt+<list_index + 1> takes one away.
var list_index: int
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
add_button.pressed.connect(add_point)
add_button.set_shortcut(create_button_shortcut(list_index, false))
minus_button.pressed.connect(minus_point)
minus_button.set_shortcut(create_button_shortcut(list_index, true))
character.pressed.connect(change_character)
remove_player.pressed.connect(func() -> void: player_removed.emit())
update_score()
set_player_character()
func _process(delta: float) -> void:
remove_player.visible = Settings.edit_players
func create_button_shortcut(index: int, ctrl_pressed: bool) -> Shortcut:
var button_shortcut: Shortcut = Shortcut.new()
var button_event : InputEventKey = InputEventKey.new()
button_event.keycode = KEY_1 + index
button_event.alt_pressed = true
button_event.ctrl_pressed = ctrl_pressed
button_shortcut.events.append(button_event)
return button_shortcut
func add_point() -> void:
player_object.add_point()
update_score()
func minus_point() -> void:
player_object.minus_point()
update_score()
func change_character() -> void:
change_character_clicked.emit()
func new_character_selected(character_texture: ImageTexture) -> void:
print("Back in player list with new")
player_object.character = character_texture
set_player_character()
func set_player_character() -> void:
character.custom_minimum_size = Vector2(80, 40)
character.ignore_texture_size = true
character.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
character.texture_normal = player_object.character
func update_score() -> void:
if OS.has_feature("debug"):
player_name_field.text = str(list_index + 1) + " " + player_object.player_name + ": " + str(player_object.player_score)
else:
player_name_field.text = player_object.player_name + ": " + str(player_object.player_score)
-103
View File
@@ -1,103 +0,0 @@
[gd_scene load_steps=9 format=3 uid="uid://cslogy2csnd7a"]
[ext_resource type="Script" uid="uid://dl6no2wr2c86y" path="res://Player.gd" id="1_if4kc"]
[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"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_821k2"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w10e8"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_78a8g"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i56hb"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_b2crt"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[node name="Player" type="Control"]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 3
anchors_preset = 0
offset_bottom = 40.0
size_flags_horizontal = 3
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_override_styles/panel = SubResource("StyleBoxFlat_821k2")
[node name="Name" type="Label" parent="HBoxContainer"]
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
[node name="Panel3" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(16, 2.08165e-12)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_w10e8")
[node name="Character" type="TextureButton" parent="HBoxContainer"]
custom_minimum_size = Vector2(80, 40)
layout_mode = 2
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)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_78a8g")
[node name="AddPoint" type="Button" parent="HBoxContainer"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "+1"
[node name="Panel5" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(3, 40)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_i56hb")
[node name="RemovePoint" type="Button" parent="HBoxContainer"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "-1"
[node name="Panel" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(30, 40)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_b2crt")
[node name="RemovePlayer" type="TextureButton" parent="HBoxContainer"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
focus_mode = 0
action_mode = 0
texture_normal = ExtResource("3_j3uxe")
stretch_mode = 0
-38
View File
@@ -1,38 +0,0 @@
extends TextEdit
signal enter_key_pressed
signal close_pressed
@export
var LIMIT: int = 10
var current_text = ''
var cursor_line = 0
var cursor_column = 0
func _ready():
text_changed.connect(_on_text_changed)
func _input(event):
if event is InputEventKey and event.pressed:
if event.alt_pressed && event.keycode == KEY_A:
close_pressed.emit()
if has_focus():
if event.keycode == KEY_ENTER || event.keycode == KEY_KP_ENTER:
enter_key_pressed.emit()
func _on_text_changed():
if text.contains("\n"):
text = text.replace("\n", "")
var new_text : String = text
if new_text.length() > LIMIT:
text = current_text
# when replacing the text, the cursor will get moved to the beginning of the
# text, so move it back to where it was
set_caret_line(cursor_line)
set_caret_column(cursor_column)
current_text = text
# save current position of cursor for when we have reached the limit
cursor_line = get_caret_line()
cursor_column = get_caret_column()
-1
View File
@@ -1 +0,0 @@
uid://cgakje8gb37tf
-66
View File
@@ -1,66 +0,0 @@
extends RefCounted
class_name PlayerObject
signal first_point_triggerd
signal match_point_triggerd
signal winner_triggerd
signal point_given_sound
signal player_point_given
signal player_point_taken
## Unique id that never changes, unlike the player's position in Settings.player_array.
var id: String = _generate_uuid()
@export
var player_name: String
@export
var player_score: int
var is_first_point: bool = true
## Picture shown until the player picks a character.
const NO_CHARACTER_PATH: String = "res://noCharacter.png"
var character: Texture = load(NO_CHARACTER_PATH)
func _init(new_name: String) -> void:
player_name = new_name
player_score = 0
## 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)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
var hex: String = bytes.hex_encode()
return "%s-%s-%s-%s-%s" % [hex.substr(0, 8), hex.substr(8, 4), hex.substr(12, 4), hex.substr(16, 4), hex.substr(20, 12)]
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_triggerd.emit()
elif player_score == Settings.winning_score - 1:
match_point_triggerd.emit()
elif first_point:
first_point_triggerd.emit()
else:
point_given_sound.emit()
func minus_point() -> void:
if player_score > 0:
player_score -= 1
player_point_taken.emit()
if player_score == 0:
is_first_point = true
func reset_points() -> void:
player_score = 0
is_first_point = true
-30
View File
@@ -1,30 +0,0 @@
extends PopupWindow
@onready
var qr_panel: Panel = $QRPanel
@onready
var qr_code_rect: QRCodeRect = $QRPanel/QRCodeRect
@onready
var url_label: Label = $QRPanel/URLLabel
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
print("QR code generated for: ", 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
+13 -1
View File
@@ -5,7 +5,7 @@ Releases are made from the version in `project.godot`
same version in the About window.
1. Bump the version, e.g. from `1.9.0` to `1.10.0`, and add a `WHATS_NEW` entry in
`AboutWindow.gd`.
`popups/about_popup.gd`.
2. Merge to `main` and push.
The `Build` workflow (`.gitea/workflows/gitea-build.yaml`) then:
@@ -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.
-185
View File
@@ -1,185 +0,0 @@
extends PopupWindow
@onready
var search_list: VBoxContainer = $ScrollContainer/VBoxContainer
@onready
var search_panel: Panel = %SearchPanel
@onready
var clear_button: Button = $ClearButton
@onready
var search_bar: TextEdit = $Searchbar
var soundtracks: Array = []
# Precomputed soundtrack data: array of dicts with original, lowercase, and cleaned fields
var precomputed_soundtracks: Array = []
# Regex cache: maps cleaned search terms to compiled RegEx objects
var regex_cache: Dictionary = {}
var label_pool: Array[Label] = []
# Called when the node enters the scene tree for the first time.
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 self.visible == true:
search_bar.grab_focus()
clear()
func close() -> void:
clear()
super()
func search() -> void:
print(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 in range(precomputed_soundtracks.size()):
var st_data = 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 in range(precomputed_soundtracks.size()):
var st_data = 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 = compile_regex_cached(clean_search_text)
for i in range(precomputed_soundtracks.size()):
var st_data = 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 a regex pattern with caching to avoid recompiling the same pattern multiple times.
func compile_regex_cached(search_term: String) -> RegEx:
if not search_term in regex_cache:
var regex = RegEx.new()
var regText: String = ".*"
for letter: String in search_term:
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
if "\\^$.|?*+()[]{}".contains(letter):
regText += "\\"
regText += letter + ".*"
regex.compile(regText)
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)
# Optimized version that reuses label nodes from pool instead of creating new ones
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)
else:
label = Label.new()
label.text = soundtrack
label.autowrap_mode = TextServer.AUTOWRAP_WORD
search_list.add_child(label)
label.visible = true
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
func get_list_of_soundtracks() -> void:
print("get_list_of_soundtracks")
var handle_soundtracks: Callable = func handle_soundtracks(array) -> 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:
print("Unexpected data")
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
## Replaces the searchable soundtracks and precomputes their search forms.
func set_soundtracks(list: Array) -> 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)
})
func clear() -> void:
search_bar.text = ""
show_all_soundtracks()
search_bar.grab_focus()
-52
View File
@@ -1,52 +0,0 @@
[gd_scene format=3 uid="uid://bxydgil1yifps"]
[ext_resource type="Script" uid="uid://bg5yo08638600" path="res://SearchWindow.gd" id="1_m7bij"]
[ext_resource type="Theme" path="res://popup_theme.tres" id="popup_theme"]
[node name="Control" type="Control" unique_id=616238617 node_paths=PackedStringArray("panel")]
theme = ExtResource("popup_theme")
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_m7bij")
panel = NodePath("SearchPanel")
[node name="SearchPanel" type="Panel" parent="." unique_id=1262945994]
unique_name_in_owner = true
layout_mode = 0
offset_right = 1152.0
offset_bottom = 640.0
[node name="ClearButton" type="Button" parent="." unique_id=1549439398]
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"
[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="VBoxContainer" type="VBoxContainer" parent="ScrollContainer" unique_id=1567403144]
layout_mode = 2
size_flags_horizontal = 3
-38
View File
@@ -1,38 +0,0 @@
extends Node
const MAX_NUMBER_OF_PLAYERS: int = 7
var default_path: String = "https://music.sanplex.xyz"
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
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
var player_array: Array[PlayerObject]
var edit_players: bool = false
var currently_syncing: bool = false
var character_select_open: bool = false
## 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", "")
func delete_children(node: Node) -> void:
for n: Node in node.get_children():
node.remove_child(n)
n.queue_free()
-218
View File
@@ -1,218 +0,0 @@
extends PopupWindow
@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 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
signal server_changed
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
fullscreen_button.button_pressed = Settings.fullscreen
select_server_button.selected = Settings.selected_server
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)
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)
visibility_changed.connect(_on_visibility_changed)
# Settings can change outside this window, e.g. winning turns statistics off
func _on_visibility_changed() -> void:
if visible:
add_to_database_button.button_pressed = Settings.add_to_stats
func fullscreen() -> void:
Settings.fullscreen = !Settings.fullscreen
if Settings.fullscreen == true:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
if Settings.fullscreen == false:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
func local_play() -> void:
Settings.play_local = !Settings.play_local
func stop_after_current() -> void:
Settings.stop_after_current = !Settings.stop_after_current
func hide_next_track() -> void:
Settings.hide_next_track = !Settings.hide_next_track
func add_to_database() -> void:
Settings.add_to_stats = !Settings.add_to_stats
func low_played() -> void:
Settings.use_low_played_mode = !Settings.use_low_played_mode
func hide_beginning() -> void:
Settings.hide_beginning = !Settings.hide_beginning
update_hide_for_seconds_enabled()
func update_hide_for_seconds_enabled() -> void:
var enabled: bool = Settings.hide_beginning
%HideForSecondsHBoxContainer.visible = enabled
func hide_length() -> void:
Settings.hide_length = !Settings.hide_length
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)
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
cache_label.text = str(Playlist.number_of_tracks_to_preload)
func increase_cache() -> void:
Playlist.number_of_tracks_to_preload += 1
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:
print("select_server")
Settings.default_path = select_server_button.get_item_text(new_server)
Settings.selected_server = new_server
print("Settings.default_path: " + Settings.default_path)
print("Settings.selected_server: " + str(Settings.selected_server))
print("new_server: " + str(new_server))
print("select_server_button.get_item_text(new_server): " + select_server_button.get_item_text(new_server))
server_changed.emit()
func set_selected_server(new_server: int) -> void:
print("set_selected_server")
select_server_button.select(new_server)
Settings.default_path = select_server_button.get_item_text(new_server)
Settings.selected_server = new_server
print("Settings.default_path: " + Settings.default_path)
print("Settings.selected_server: " + str(Settings.selected_server))
print("new_server: " + str(new_server))
print("select_server_button.get_item_text(new_server): " + select_server_button.get_item_text(new_server))
server_changed.emit()
-259
View File
@@ -1,259 +0,0 @@
[gd_scene format=3 uid="uid://dldpeo5y3l5hq"]
[ext_resource type="Script" uid="uid://dukqyjoduf1af" path="res://SettingsWindow.gd" id="1_bt55j"]
[ext_resource type="Theme" path="res://popup_theme.tres" id="popup_theme"]
[sub_resource type="LabelSettings" id="LabelSettings_3xrlm"]
font_size = 25
[node name="SettingsControl" type="Control" unique_id=1298840328 node_paths=PackedStringArray("panel")]
theme = ExtResource("popup_theme")
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_bt55j")
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
[node name="ScrollContainer" type="ScrollContainer" parent="SettingsPanel" unique_id=1325795358]
layout_mode = 0
offset_left = 20.0
offset_top = 8.0
offset_right = 480.0
offset_bottom = 632.0
horizontal_scroll_mode = 0
[node name="VBoxContainer" type="VBoxContainer" parent="SettingsPanel/ScrollContainer" unique_id=1796635163]
clip_contents = true
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
text = "Settings"
label_settings = SubResource("LabelSettings_3xrlm")
horizontal_alignment = 1
[node name="StopAfterCurrentCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=508305575]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
button_pressed = true
action_mode = 0
text = "Stop after current"
[node name="HideNextTrackCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1568244599]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
button_pressed = true
action_mode = 0
text = "Hide next track"
[node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1673750542]
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"
[node name="IncreaseHideForSecondsButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=340681770]
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="HideLengthCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=67806372]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
action_mode = 0
text = "Hide length of songs"
[node name="HideTicksCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=706322752]
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
[node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=400304684]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
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]
unique_name_in_owner = true
visible = false
layout_mode = 2
size_flags_horizontal = 3
disabled = true
text = "Local"
-55
View File
@@ -1,55 +0,0 @@
extends RefCounted
class_name SongObject
var song_number: int
var song_title: String
var soundtrack_title: String
## Ids of the players who got a point for this song.
var players_given_point: Array[String] = []
var is_answered: bool = false
var has_played: bool = false
var is_playing: bool = false
var song: AudioStream
func get_soundtrack_title() -> String:
if is_answered:
return soundtrack_title
else:
return "????????"
func get_song_title() -> String:
if is_answered:
return song_title
else:
return "??????"
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]
elif is_answered:
var format_string: String = "%d. %s - %s"
return format_string % [(song_number + 1), soundtrack_title, song_title]
else:
return "??? - ???"
func add_point(player_id: String) -> void:
print("add_point")
if !players_given_point.has(player_id):
players_given_point.append(player_id)
func remove_point(player_id: String) -> void:
print("remove_point")
players_given_point.erase(player_id)
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"
return format_string % [(song_number), soundtrack_title, song_title, is_answered, has_played, is_playing]
+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`, `SyncWindow.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
-56
View File
@@ -1,56 +0,0 @@
[gd_scene format=3 uid="uid://cxlsmxnrp0agh"]
[ext_resource type="Script" uid="uid://dxpb22xbwf3t1" path="res://winner_window.gd" id="1_winner_script"]
[ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="2_no_character"]
[ext_resource type="Theme" path="res://popup_theme.tres" id="popup_theme"]
[sub_resource type="LabelSettings" id="LabelSettings_hr75l"]
font_size = 35
[node name="WinnerControl" type="Control" unique_id=1111111111 node_paths=PackedStringArray("panel")]
theme = ExtResource("popup_theme")
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_winner_script")
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
[node name="VBoxContainer" type="VBoxContainer" parent="WinnerPanel" unique_id=471159349]
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
[node name="WinnerPicture" type="TextureRect" parent="WinnerPanel/VBoxContainer" unique_id=1543774067]
unique_name_in_owner = true
custom_minimum_size = Vector2(480, 200)
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]
unique_name_in_owner = true
layout_mode = 2
text = "Winner!"
label_settings = SubResource("LabelSettings_hr75l")
horizontal_alignment = 1
vertical_alignment = 2
[node name="PlayersList" type="VBoxContainer" parent="WinnerPanel/VBoxContainer" unique_id=471159350]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
-46
View File
@@ -1,46 +0,0 @@
class_name Character
extends Control
@onready
var panel: Panel = $Panel
@onready
var character_image: TextureRect = %CharacterTextureRect
@onready
var character_name_label: Label = %CharacterName
signal new_character_selected(img_texture: ImageTexture)
func set_character_name(character_name: String) -> void:
panel.size = Vector2(240, 145)
#margin.size = Vector2(240, 145)
#margin set margin 15, 10, 15, 0
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:
print(err)
var err2: Error = img.load_jpg_from_buffer(image)
if err2 != OK:
print(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)
func _input(event: InputEvent) -> void:
if self.visible == true && Settings.character_select_open == true:
if event is InputEventMouseButton && event.is_pressed() && event.get_button_index() == MOUSE_BUTTON_LEFT:
var evLocal: InputEvent = make_input_local(event)
if Rect2(Vector2(0, 0), panel.size).has_point(evLocal.position):
print(character_name_label.text)
new_character_selected.emit(character_image.texture)
-45
View File
@@ -1,45 +0,0 @@
[gd_scene load_steps=4 format=3 uid="uid://c7ecjj2af6usf"]
[ext_resource type="Script" uid="uid://i35b3scbpwpu" path="res://character.gd" id="1_pjt0s"]
[ext_resource type="Texture2D" uid="uid://cv3hdd6bsruau" path="res://icon.svg" id="2_pjt0s"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjt0s"]
bg_color = Color(0.302, 0.302, 0.302, 1)
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 0
script = ExtResource("1_pjt0s")
[node name="Panel" type="Panel" parent="."]
layout_mode = 0
offset_right = 240.0
offset_bottom = 145.0
theme_override_styles/panel = SubResource("StyleBoxFlat_pjt0s")
[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"]
unique_name_in_owner = true
custom_minimum_size = Vector2(200, 100)
layout_mode = 2
size_flags_horizontal = 4
size_flags_vertical = 4
texture = ExtResource("2_pjt0s")
expand_mode = 3
stretch_mode = 5
[node name="CharacterName" type="Label" parent="Panel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Aloy"
horizontal_alignment = 1
+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
+21
View File
@@ -0,0 +1,21 @@
extends Node
## Writes messages about what the game is doing, for finding problems.
##
## Registered as the [code]GameLog[/code] autoload. Use [method debug] instead of
## [code]print()[/code]: the messages show in debug builds and the editor, and are left
## out of released games. Problems the player may notice go through [method warning].
## Prints [param parts] joined together, like [code]print()[/code], in debug builds only.
func debug(...parts: Array) -> void:
if OS.is_debug_build():
print(_join(parts))
## Reports a problem in the editor's debugger and the console, in every build.
func warning(...parts: Array) -> void:
push_warning(_join(parts))
func _join(parts: Array) -> String:
return "".join(parts.map(func(part: Variant) -> String: return str(part)))
+1
View File
@@ -0,0 +1 @@
uid://c37mlo6svk03q
+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
+34
View File
@@ -0,0 +1,34 @@
class_name ListRow
extends PanelContainer
## One item in a list of names, such as the inspiration list or a sync result column.
##
## Every row has its own background, alternating between the theme's [code]ListRowEven[/code]
## and [code]ListRowOdd[/code] styles, so it is clear where an item starts even when its
## text wraps over several lines.
## The label showing the item's text.
var label: Label
## The item's text.
var text: String:
get:
return label.text
## Creates the row for item number [param row] (counting from 0) in a list. The text uses
## the theme's [param label_style], e.g. [code]TextLarge[/code].
static func create(
item_text: String,
row: int,
label_style: StringName,
alignment: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT,
) -> ListRow:
var list_row: ListRow = ListRow.new()
list_row.theme_type_variation = &"ListRowOdd" if row % 2 != 0 else &"ListRowEven"
list_row.label = Label.new()
list_row.label.theme_type_variation = label_style
list_row.label.text = item_text
list_row.label.horizontal_alignment = alignment
list_row.label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
list_row.add_child(list_row.label)
return list_row
File renamed without changes.
+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
+110
View File
@@ -0,0 +1,110 @@
class_name PlayerObject
extends RefCounted
## A player in the quiz: name, score and character. The players are kept in
## [code]Settings.player_array[/code].
signal first_point_triggered
signal match_point_triggered
signal winner_triggered
signal point_given_sound
signal player_point_given
signal player_point_taken
## Picture shown until the player picks a character.
const NO_CHARACTER_PATH: String = "res://noCharacter.png"
@export var player_name: String
@export var player_score: int
## Unique id that never changes, unlike the player's position in Settings.player_array.
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:
player_name = new_name
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)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
var hex: String = bytes.hex_encode()
return "%s-%s-%s-%s-%s" % [hex.substr(0, 8), hex.substr(8, 4), hex.substr(12, 4),
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:
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:
if player_score > 0:
player_score -= 1
player_point_taken.emit()
if player_score == 0:
is_first_point = true
func reset_points() -> void:
player_score = 0
is_first_point = true
File renamed without changes.
+201
View File
@@ -0,0 +1,201 @@
extends Node
## The songs of the current quiz, in the order they were added, and which of them are
## 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
var currently_playing_song: int = -1
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:
return song_object_array[latest_played_song]
func set_latest_played_song() -> void:
latest_played_song += 1
if latest_played_song > song_object_array.size() - 1:
latest_played_song = song_object_array.size() - 1
func update_last_played_song() -> void:
latest_played_song = song_object_array.size()
func get_preloaded_songs() -> int:
var number_of_tracks_preloaded: int = 0
for song: SongObject in song_object_array:
if !song.has_played:
number_of_tracks_preloaded += 1
return number_of_tracks_preloaded
func should_preload_more_songs() -> bool:
return number_of_tracks_to_preload > get_preloaded_songs()
func song_is_answered(song_no: int = currently_playing_song) -> void:
# Nothing is playing yet (-1); a negative index would answer the last, unplayed song
if song_no < 0 || song_no >= song_object_array.size():
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 && !Settings.is_offline():
GameLog.debug("add to stats: ", song_no)
MusicServerClient.mark_song_played(song_no)
func add_to_playlist(new_song_object: SongObject) -> void:
song_object_array.append(new_song_object)
func get_playlist() -> Array[SongObject]:
if Settings.is_debug:
return song_object_array
else:
var temp_list: Array[SongObject] = []
for s: SongObject in song_object_array:
if s.has_played:
temp_list.append(s)
return temp_list
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:
song_object_array.clear()
func unset_is_playing() -> void:
for song_object: SongObject in song_object_array:
song_object.is_playing = false
func set_currently_playing_song(value: int) -> void:
currently_playing_song = value
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()
func add_point(player_id: String) -> void:
song_object_array[currently_playing_song].add_point(player_id)
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:
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, 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:
song_object_array[song_no].has_played = true
func song_is_playing(song_no: int = latest_played_song) -> void:
song_object_array[song_no].is_playing = true
File renamed without changes.
+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
+299
View File
@@ -0,0 +1,299 @@
extends Node
## The game settings and the players, shared by the whole game.
##
## 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_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
## 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 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)
n.queue_free()
File renamed without changes.
+109
View File
@@ -0,0 +1,109 @@
extends RefCounted
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] = []
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:
if is_answered:
return soundtrack_title
else:
return "????????"
func get_song_title() -> String:
if is_answered:
return song_title
else:
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:
return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(),
has_played, is_answered]
elif is_answered:
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):
players_given_point.append(player_id)
func remove_point(player_id: String) -> void:
GameLog.debug("remove_point")
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"
return format_string % [song_number, soundtrack_title, song_title, is_answered, has_played,
is_playing]
File renamed without changes.
+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
-6
View File
@@ -1,6 +0,0 @@
[gd_resource type="Theme" load_steps=2 format=3 uid="uid://8bllart6xn1f"]
[ext_resource type="StyleBox" uid="uid://bsv6yb0yir04l" path="res://style_box_flat.tres" id="1_uierc"]
[resource]
PanelContainer/styles/panel = ExtResource("1_uierc")
+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.
-42
View File
@@ -1,42 +0,0 @@
class_name HttpTransport
extends RefCounted
## Sends HTTP requests for [code]MusicServerClient[/code] using [HTTPRequest] nodes.
##
## Tests subclass this to answer requests without a network connection.
## Seconds before a request is given up and reported as failed. This covers the whole
## download, so it is generous enough for long songs on a slow connection.
var timeout_seconds: float = 120.0
var _owner: Node
func _init(owner: Node) -> void:
_owner = owner
## Requests [param url] and calls [param on_complete] with
## [code](response_code: int, body: PackedByteArray)[/code]. Connection errors and
## timeouts are reported with response code 0.
func request(url: String, method: int, on_complete: Callable) -> void:
var http_request: HTTPRequest = HTTPRequest.new()
http_request.timeout = timeout_seconds
_owner.add_child(http_request)
http_request.request_completed.connect(
func(
result: int,
response_code: int,
_headers: PackedStringArray,
body: PackedByteArray,
) -> void:
http_request.queue_free()
on_complete.call(response_code if result == HTTPRequest.RESULT_SUCCESS else 0, body)
)
var headers: PackedStringArray = []
if method == HTTPClient.METHOD_PUT:
headers.append("Content-Type: application/json")
var error: int = http_request.request(url, headers, method)
if error != OK:
http_request.queue_free()
push_error("Could not send request to %s (error %d)" % [url, error])
on_complete.call(0, PackedByteArray())
+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
+17 -17
View File
@@ -5,20 +5,20 @@ extends SceneTree
func _initialize() -> void:
var args: PackedStringArray = OS.get_cmdline_user_args()
if args.size() != 3:
printerr("Usage: godot --headless -s render_icon.gd -- <input.svg> <output.png> <size>")
quit(1)
return
var svg: String = FileAccess.get_file_as_string(args[0])
var source: Image = Image.new()
# Load at 1x to read the SVG's own size, then scale so the result is <size> pixels
var error: int = source.load_svg_from_string(svg)
if error == OK:
var image: Image = Image.new()
error = image.load_svg_from_string(svg, float(args[2]) / source.get_width())
if error == OK:
error = image.save_png(args[1])
if error != OK:
printerr("Could not render %s to %s (error %d)" % [args[0], args[1], error])
quit(error)
var args: PackedStringArray = OS.get_cmdline_user_args()
if args.size() != 3:
printerr("Usage: godot --headless -s render_icon.gd -- <input.svg> <output.png> <size>")
quit(1)
return
var svg: String = FileAccess.get_file_as_string(args[0])
var source: Image = Image.new()
# Load at 1x to read the SVG's own size, then scale so the result is <size> pixels
var error: int = source.load_svg_from_string(svg)
if error == OK:
var image: Image = Image.new()
error = image.load_svg_from_string(svg, float(args[2]) / source.get_width())
if error == OK:
error = image.save_png(args[1])
if error != OK:
printerr("Could not render %s to %s (error %d)" % [args[0], args[1], error])
quit(error)
-57
View File
@@ -1,57 +0,0 @@
class_name ListRow
extends PanelContainer
## One item in a list of names, such as the inspiration list or a sync result column.
##
## Every row has its own background, alternating between two shades, so it is clear
## where an item starts even when its text wraps over several lines.
const EVEN_BACKGROUND: Color = Color(1, 1, 1, 0.05)
const ODD_BACKGROUND: Color = Color(1, 1, 1, 0.11)
static var _even_style: StyleBoxFlat
static var _odd_style: StyleBoxFlat
## The label showing the item's text.
var label: Label
## The item's text.
var text: String:
get:
return label.text
## Creates the row for item number [param row] (counting from 0) in a list.
static func create(
item_text: String,
row: int,
font_size: int,
alignment: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT,
) -> ListRow:
var list_row: ListRow = ListRow.new()
list_row.add_theme_stylebox_override("panel", _style(row % 2 != 0))
list_row.label = Label.new()
list_row.label.add_theme_font_size_override("font_size", font_size)
list_row.label.text = item_text
list_row.label.horizontal_alignment = alignment
list_row.label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
list_row.add_child(list_row.label)
return list_row
# Rows share two styles instead of each creating its own
static func _style(odd: bool) -> StyleBoxFlat:
if _even_style == null:
_even_style = _make_style(EVEN_BACKGROUND)
_odd_style = _make_style(ODD_BACKGROUND)
return _odd_style if odd else _even_style
static func _make_style(background: Color) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = background
style.set_corner_radius_all(4)
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 2
style.content_margin_bottom = 2
return style
-25
View File
@@ -1,25 +0,0 @@
extends PopupWindow
@onready
var log_panel: Panel = %LogPanel
@onready
var log_box: VBoxContainer = %LogVBoxContainer
var log_rows: Array
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.add_theme_font_size_override("font_size", 20)
log_label.text = row
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD
return log_label
func get_time() -> String:
var time: Dictionary = Time.get_time_dict_from_system()
return "%02d:%02d:%02d " % [time.hour, time.minute, time.second]
+131
View File
@@ -0,0 +1,131 @@
[gd_scene format=3 uid="uid://xwq863o6uvsu"]
[ext_resource type="PackedScene" path="res://main/TopBar.tscn" id="top_bar_scene"]
[ext_resource type="PackedScene" path="res://quiz/Quiz.tscn" id="quiz_scene"]
[ext_resource type="Script" uid="uid://b8f4g15cas2j2" path="res://main/main_window.gd" id="1_eu0t5"]
[ext_resource type="PackedScene" uid="uid://bxydgil1yifps" path="res://popups/SearchPopup.tscn" id="9_5ijvr"]
[ext_resource type="PackedScene" uid="uid://dpdem7pdxweb5" path="res://popups/SyncPopup.tscn" id="10_yxw0b"]
[ext_resource type="PackedScene" uid="uid://bd1by80q1v27e" path="res://popups/ShortcutsPopup.tscn" id="13_8eebo"]
[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
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_eu0t5")
[node name="Background" type="Panel" parent="." unique_id=1093825566]
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 = &"WindowBackground"
[node name="TopBar" parent="." unique_id=1734261695 instance=ExtResource("top_bar_scene")]
[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 = 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 = 220.0
offset_right = 210.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 = 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 = 610.0
offset_top = 187.0
offset_right = 610.0
offset_bottom = 187.0
[node name="AboutPopup" parent="." unique_id=141261008 instance=ExtResource("16_about_window")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 360.0
offset_top = 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 = 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
+151
View File
@@ -0,0 +1,151 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://main/top_bar.gd" id="top_bar_script"]
[sub_resource type="ButtonGroup" id="ButtonGroup_tabs"]
[node name="TopBar" type="Control" unique_id=1489162753]
layout_mode = 3
anchors_preset = 10
anchor_right = 1.0
offset_bottom = 56.0
grow_horizontal = 2
mouse_filter = 2
script = ExtResource("top_bar_script")
[node name="TopPanelContainer" type="PanelContainer" parent="." unique_id=663404711]
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
offset_left = 64.0
offset_top = 12.0
offset_right = 369.0
offset_bottom = 50.0
size_flags_horizontal = 0
theme_type_variation = &"Title"
text = "Music Player Randomizer"
[node name="DebugLabel" type="Label" parent="." unique_id=1959221257]
visible = false
layout_mode = 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="SettingsButton" type="Button" parent="Buttons" unique_id=1228313557]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Settings"
[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="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="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"
+209
View File
@@ -0,0 +1,209 @@
class_name MainWindow
extends Control
## 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.
## 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
@onready var top_bar: TopBar = $TopBar
@onready var quiz: Quiz = $Quiz
@onready var search_popup: SearchPopup = $SearchPopup
@onready var sync_popup: SyncPopup = %SyncPopup
@onready var shortcuts_popup: ShortcutsPopup = %ShortcutsPopup
@onready var qr_popup: QRPopup = %QRPopup
@onready var about_popup: AboutPopup = %AboutPopup
@onready var log_popup: LogPopup = %LogPopup
@onready var settings_popup: SettingsPopup = %SettingsPopup
@onready var update_popup: UpdatePopup = %UpdatePopup
@onready var 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")
quiz.search_pressed.connect(show_search)
top_bar.sync_pressed.connect(sync_soundtracks)
top_bar.tab_selected.connect(show_tab)
top_bar.shortcuts_pressed.connect(show_shortcuts)
top_bar.about_pressed.connect(show_about)
top_bar.qr_pressed.connect(show_qr)
top_bar.settings_pressed.connect(settings_popup.open)
top_bar.update_pressed.connect(update_popup.show_update)
update_popup.restarting.connect(quiz.save_now)
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.set_fullscreen(!Settings.fullscreen)
if event.alt_pressed && event.keycode == KEY_K:
shortcuts_popup.toggle()
if event.alt_pressed && event.keycode == KEY_L:
log_popup.toggle()
func _popups() -> Array[BasePopup]:
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:
GameLog.debug("show_search")
search_popup.toggle()
func show_about() -> void:
GameLog.debug("show_about")
about_popup.open()
func show_shortcuts() -> void:
GameLog.debug("show_shortcuts")
shortcuts_popup.open()
func show_qr() -> void:
GameLog.debug("show_qr")
qr_popup.open()
## 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:
GameLog.debug("sync_soundtracks")
sync_popup.start_sync()
func _on_sync_started() -> void:
GameLog.debug("_on_sync_started")
top_bar.sync_button.disabled = false
quiz.lock_for_sync()
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()
File renamed without changes.
+117
View File
@@ -0,0 +1,117 @@
class_name TopBar
extends Control
## The bar along the top of the main window, with the title, the Quiz and Statistics tabs
## and the buttons for things that are not part of a quiz.
##
## With a music folder picked, the buttons that need the server are turned off and the
## folder's name is shown.
## Shown on the buttons that need the server while a music folder is used.
const OFFLINE_TOOLTIP: String = "Not available with a music folder"
signal sync_pressed
signal settings_pressed
signal shortcuts_pressed
signal about_pressed
signal qr_pressed
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
signal tab_selected(tab: StringName)
## Emitted when "Version X is available" is clicked, with the newest version.
signal update_pressed(latest_version: String)
## 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:
# Debug builds use the local server, see Settings
debug_label.visible = Settings.is_debug
sync_button.pressed.connect(sync_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")
+1
View File
@@ -0,0 +1 @@
uid://bb2ukabiec1vv
-139
View File
@@ -1,139 +0,0 @@
extends Node
## Client for the MusicServer REST API.
##
## Registered as the [code]MusicServerClient[/code] autoload, so every script can call it
## directly, e.g. [code]MusicServerClient.fetch_song_list(on_done)[/code]. All HTTP traffic
## goes through [member transport]; tests replace it with a fake to answer requests
## without a running server.
## Sends the actual HTTP requests. Swap it out in tests.
var transport: HttpTransport
func _ready() -> void:
if transport == null:
transport = HttpTransport.new(self)
## Fetches the songs already in the server playlist, as an Array of
## [code]{SongNo, Soundtrack, Song}[/code] dictionaries.
func fetch_song_list(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json("/music/list", on_done, on_failed)
## Downloads a random song as MP3 bytes. With [param low_played] the server prefers
## soundtracks that have been played the least.
func fetch_random_song(
low_played: bool, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/music/rand/low" if low_played else "/music/rand", on_done, on_failed)
## Adds the most recently fetched random song to the server playlist.
func add_to_queue(on_done: Callable, on_failed: Callable = Callable()) -> void:
send("/music/addQue", on_done, on_failed)
## Fetches [code]{SongNo, Soundtrack, Song}[/code] for the most recently queued song.
func fetch_song_info(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json("/music/info", on_done, on_failed)
## Downloads a song from the server playlist as MP3 bytes.
func fetch_song(song_no: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/music?song=" + str(song_no), on_done, on_failed)
## Downloads the sound test song as MP3 bytes.
func fetch_sound_test_song(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/music/soundTest", on_done, on_failed)
## Clears the server playlist.
func reset_playlist(on_done: Callable, on_failed: Callable = Callable()) -> void:
send("/music/reset", on_done, on_failed)
## Fetches every soundtrack name as an Array of Strings.
func fetch_all_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json("/music/all", on_done, on_failed)
## Fetches every soundtrack name, sorted, as an Array of Strings.
func fetch_all_soundtracks_ordered(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json("/music/all/order", on_done, on_failed)
## Records in the server statistics that a song has been played.
func mark_song_played(song_no: int) -> void:
send("/music/played?song=" + str(song_no), Callable(), Callable(), HTTPClient.METHOD_PUT)
## Fetches the names of all character images.
func fetch_character_list(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json("/characters", on_done, on_failed)
## Downloads a character image as PNG bytes.
func fetch_character_image(
character_name: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/character?name=" + character_name.uri_encode(), on_done, on_failed)
## Starts syncing the server's soundtrack library with its music folder.
func start_sync(on_done: Callable, on_failed: Callable = Callable()) -> void:
send("/sync", on_done, on_failed)
## Fetches the sync state: [code]{progress, time_spent}[/code] while running, or the
## sync result once finished.
func fetch_sync_progress(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json("/sync/progress", on_done, on_failed)
## Requests [param path] and calls [param on_done] with the parsed JSON.
## [param on_failed] is called with no arguments if the request fails or the body is
## not valid JSON.
func get_json(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(path, HTTPClient.METHOD_GET, func(body: PackedByteArray) -> void:
var json: JSON = JSON.new()
if json.parse(body.get_string_from_utf8()) != OK:
push_warning("Invalid JSON from %s: %s" % [path, json.get_error_message()])
_call_if_valid(on_failed)
return
on_done.call(json.data)
, on_failed)
## Requests [param path] and calls [param on_done] with the raw response body, or
## [param on_failed] with no arguments if the request fails.
func get_bytes(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(path, HTTPClient.METHOD_GET, on_done, on_failed)
## Requests [param path] and calls [param on_done] with no arguments when it succeeds,
## or [param on_failed] when it fails. Either may be an empty Callable.
func send(
path: String,
on_done: Callable,
on_failed: Callable = Callable(),
method: int = HTTPClient.METHOD_GET,
) -> void:
_request(path, method, func(_body: PackedByteArray) -> void:
_call_if_valid(on_done)
, on_failed)
func _request(path: String, method: int, on_success: Callable, on_failed: Callable) -> void:
var url: String = Settings.default_path + path
transport.request(url, method, func(response_code: int, body: PackedByteArray) -> void:
if response_code != 200:
push_warning("Request to %s failed with response code %d" % [url, response_code])
_call_if_valid(on_failed)
return
on_success.call(body)
)
static func _call_if_valid(callback: Callable) -> void:
if callback.is_valid():
callback.call()
-4
View File
@@ -1,4 +0,0 @@
[gd_resource type="StyleBoxFlat" format=3 uid="uid://blf3g88mugovr"]
[resource]
bg_color = Color(0.257813, 0.257813, 0.257813, 1)
-65
View File
@@ -1,65 +0,0 @@
extends PopupWindow
@onready
var character_panel: Panel = %CharacterPanel
@onready
var title_label: Label = %TitleLabel
@onready
var character_grid: GridContainer = %CharacterGridContainer
@onready
var close_timer: Timer = %CloseTimer
var can_select_character: bool = false
signal new_new_character_selected(file_name: String)
var character: PackedScene = preload("res://character.tscn")
func _ready() -> void:
close_timer.timeout.connect(_on_close_timeout)
func _on_close_timeout() -> void:
print("_on_close_timeout!")
Settings.character_select_open = false
close_timer.stop()
func load_characters() -> void:
print("load_characters")
var fetch_character_list: Callable = func fetch_character_list(list) -> void:
print("fetch_character_list")
Settings.delete_children(character_grid)
if typeof(list) == TYPE_ARRAY:
for character_name: String in list:
print("character_name ", character_name)
var character_box: Character = 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.connect("new_character_selected", _on_character_selected)
MusicServerClient.fetch_character_list(fetch_character_list)
func show_grid(player_name: String) -> void:
print("show_grid")
title_label.text = "Select character for " + player_name
close_timer.stop()
open()
can_select_character = true
Settings.character_select_open = true
func close() -> void:
super()
Settings.character_select_open = false
func _on_character_selected(texture: ImageTexture) -> void:
print("_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
self.visible = false
if can_select_character:
new_new_character_selected.emit(texture)
close_timer.start()
can_select_character = false
Loaded 100 of 396 files, more files were not shown because too many files have changed in this diff. Show more