Files
MusicPlayer/data/settings.gd
T
SansanandClaude Opus 5.5 a735c515bf
Build / test (push) Successful in 1m40s
Build / build (push) Successful in 1m28s
Add Short songs and a choice of how random songs are picked (#30, #51)
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

300 lines
12 KiB
GDScript

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()