Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0862d160e5 | ||
|
|
f39865fffc | ||
|
|
c03c0e4efe | ||
|
|
06421bb63e | ||
|
|
ef6ac8f1fd | ||
|
|
a735c515bf | ||
|
|
0fc3be79e1 | ||
|
|
ae231975bd | ||
|
|
b9fa3c3d63 | ||
|
|
de94691073 | ||
|
|
94e8dfb602 | ||
|
|
70e970b24c | ||
|
|
e4352a9f34 | ||
|
|
4a934840f6 | ||
|
|
78e1facaf0 | ||
|
|
84e137e16a | ||
|
|
a4e6da4c28 | ||
|
|
cd4331881c | ||
|
|
06f457b027 | ||
|
|
222d231b1b | ||
|
|
b120440404 | ||
|
|
41974ea5e3 | ||
|
|
240c331ab2 | ||
|
|
62fa63fd8b | ||
|
|
4a45e8516d | ||
|
|
d21b3e2ce0 | ||
|
|
92e3a66075 | ||
|
|
b510aa47f2 | ||
|
|
8d21b0617d | ||
|
|
b2d547b650 | ||
|
|
845d08c260 | ||
|
|
13fdb3f68c | ||
|
|
e1e05f3904 | ||
|
|
b40b160fde |
No files matched your search
@@ -27,3 +27,15 @@ Unpack `MusicPlayer_<version>_linux.tar.gz` and run `./install.sh` inside the
|
||||
`MusicPlayer` folder. It installs the game to `~/.local/bin` and adds it, with its icon,
|
||||
to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone
|
||||
who just wants to run the file.
|
||||
|
||||
## Updating
|
||||
|
||||
When the server has a newer release, the top bar says "Version X is available". Clicking
|
||||
it offers **Update and restart**: the game downloads its new file from the release
|
||||
(`MusicPlayer_<version>.exe` on Windows, `MusicPlayer_<version>.x86_64` on Linux, through
|
||||
the server's `/download/<platform>` routes), puts it in place of the running file and
|
||||
starts it, and the quiz continues where it was. On Windows the old file is kept as
|
||||
`<game>.old` until the next start. So keep the release file names as they are: the server
|
||||
finds them by their ending. Where the game can't replace itself (run from the editor, a
|
||||
folder it can't write to, or another platform), it opens the download in the browser
|
||||
instead.
|
||||
+145
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://67is3ejxq0j8
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxjx3kacfqg6j
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://drle78nqw2ve0
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4vekt3m8q1p7
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://34xwe7vs2md7
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://djxm2kkq8hol1
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://0jfq23w54ihw
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bc2nkmd162ibl
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b262vckl8hbn4
|
||||
+90
-11
@@ -1,26 +1,40 @@
|
||||
class_name SoundtrackSearch
|
||||
extends RefCounted
|
||||
## Finds soundtrack names matching a search text. Matches ignore case, spaces and
|
||||
## special characters, and also find the letters in order with others in between.
|
||||
## special characters. Searches of at least [code]Settings.LETTERS_FOR_LOOSE_SEARCH[/code]
|
||||
## letters also find the letters in order with others in between.
|
||||
##
|
||||
## Used by the Search popup and the statistics view, so both find the same soundtracks.
|
||||
##
|
||||
## A soundtrack is also found by its alternative names, e.g. "FF7" for "Final Fantasy
|
||||
## VII". The results are always the real names; [method matched_alternative] tells which
|
||||
## alternative name matched.
|
||||
|
||||
## Each name as a Dictionary with its original, lowercase and cleaned form.
|
||||
## Each searchable name as a Dictionary: the soundtrack's real name ("original"), the name
|
||||
## searched in ("text"), and its lowercase and cleaned forms.
|
||||
var _names: Array[Dictionary] = []
|
||||
# The alternative name that matched in the last search, by real name
|
||||
var _matched_alternatives: Dictionary = {}
|
||||
|
||||
## Compiled patterns for the letters-in-order search, by cleaned search term.
|
||||
var _regex_cache: Dictionary = {}
|
||||
|
||||
|
||||
## Replaces the names to search in.
|
||||
func set_names(names: Array) -> void:
|
||||
## Replaces the names to search in. [param alternatives] has the other names of a
|
||||
## soundtrack, by its real name.
|
||||
func set_names(names: Array, alternatives: Dictionary = {}) -> void:
|
||||
_names.clear()
|
||||
_matched_alternatives.clear()
|
||||
for soundtrack: String in names:
|
||||
_names.append({
|
||||
"original": soundtrack,
|
||||
"lowercase": soundtrack.to_lower(),
|
||||
"cleaned": clean_term(soundtrack),
|
||||
})
|
||||
_add_name(soundtrack, soundtrack)
|
||||
for alternative: String in alternatives.get(soundtrack, []):
|
||||
_add_name(soundtrack, alternative)
|
||||
|
||||
|
||||
## The alternative name of [param soundtrack_name] that matched in the last
|
||||
## [method find_matches], or an empty String when its real name matched.
|
||||
func matched_alternative(soundtrack_name: String) -> String:
|
||||
return _matched_alternatives.get(soundtrack_name, "")
|
||||
|
||||
|
||||
## Returns the names matching [param search_text], best matches first: plain
|
||||
@@ -30,22 +44,78 @@ func find_matches(search_text: String) -> Array[String]:
|
||||
var seen: Dictionary = {}
|
||||
var search_text_lower: String = search_text.to_lower()
|
||||
var clean_search_text: String = clean_term(search_text)
|
||||
var regex: RegEx = _compile_regex_cached(clean_search_text)
|
||||
var passes: Array[Callable] = [
|
||||
func(entry: Dictionary) -> bool: return entry.lowercase.contains(search_text_lower),
|
||||
func(entry: Dictionary) -> bool: return entry.cleaned.contains(clean_search_text),
|
||||
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null,
|
||||
]
|
||||
if clean_search_text.length() >= Settings.LETTERS_FOR_LOOSE_SEARCH:
|
||||
var regex: RegEx = _compile_regex_cached(clean_search_text)
|
||||
passes.append(
|
||||
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null)
|
||||
|
||||
var matches: Array[String] = []
|
||||
_matched_alternatives.clear()
|
||||
for matches_pass: Callable in passes:
|
||||
for entry: Dictionary in _names:
|
||||
if !seen.has(entry.original) && matches_pass.call(entry):
|
||||
seen[entry.original] = true
|
||||
matches.append(entry.original)
|
||||
if entry.text != entry.original:
|
||||
_matched_alternatives[entry.original] = entry.text
|
||||
return matches
|
||||
|
||||
|
||||
## The positions of the letters in [param soundtrack_name] that [param search_text]
|
||||
## matched, for highlighting them: the text itself if the name contains it, otherwise the
|
||||
## letters found in order the way [method find_matches] finds them. Empty if it does not
|
||||
## match.
|
||||
static func matched_letters(soundtrack_name: String, search_text: String) -> Array[int]:
|
||||
var positions: Array[int] = []
|
||||
if search_text.is_empty():
|
||||
return positions
|
||||
var start: int = soundtrack_name.to_lower().find(search_text.to_lower())
|
||||
if start >= 0:
|
||||
for i: int in search_text.length():
|
||||
positions.append(start + i)
|
||||
return positions
|
||||
|
||||
# Clean the name piece by piece, remembering which letter each cleaned letter came from
|
||||
var cleaned: String = ""
|
||||
var source: Array[int] = []
|
||||
var i: int = 0
|
||||
while i < soundtrack_name.length():
|
||||
var piece: String
|
||||
var length: int = 1
|
||||
if soundtrack_name.substr(i, 2).to_lower() == "'n":
|
||||
piece = "and"
|
||||
length = 2
|
||||
else:
|
||||
piece = clean_term(soundtrack_name[i])
|
||||
for letter: String in piece:
|
||||
cleaned += letter
|
||||
source.append(i)
|
||||
i += length
|
||||
|
||||
var clean_search_text: String = clean_term(search_text)
|
||||
var found: int = cleaned.find(clean_search_text)
|
||||
var cleaned_positions: Array[int] = []
|
||||
if found >= 0:
|
||||
for j: int in clean_search_text.length():
|
||||
cleaned_positions.append(found + j)
|
||||
elif clean_search_text.length() >= Settings.LETTERS_FOR_LOOSE_SEARCH:
|
||||
var at: int = 0
|
||||
for letter: String in clean_search_text:
|
||||
at = cleaned.find(letter, at)
|
||||
if at < 0:
|
||||
return positions
|
||||
cleaned_positions.append(at)
|
||||
at += 1
|
||||
for cleaned_position: int in cleaned_positions:
|
||||
if !positions.has(source[cleaned_position]):
|
||||
positions.append(source[cleaned_position])
|
||||
return positions
|
||||
|
||||
|
||||
## Cleans a term by removing spaces and replacing special characters.
|
||||
## Matches MusicServer's clean_term() function.
|
||||
static func clean_term(term: String) -> String:
|
||||
@@ -53,6 +123,15 @@ static func clean_term(term: String) -> String:
|
||||
.replace("'n", "and").to_lower()
|
||||
|
||||
|
||||
func _add_name(soundtrack: String, text: String) -> void:
|
||||
_names.append({
|
||||
"original": soundtrack,
|
||||
"text": text,
|
||||
"lowercase": text.to_lower(),
|
||||
"cleaned": clean_term(text),
|
||||
})
|
||||
|
||||
|
||||
# Compiles the letters-in-order pattern for [param search_term], once per term
|
||||
func _compile_regex_cached(search_term: String) -> RegEx:
|
||||
if not search_term in _regex_cache:
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://bu3bi7qhxr7it
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcrem1inl47ve
|
||||
+43
-3
@@ -21,6 +21,9 @@ var id: String = _generate_uuid()
|
||||
|
||||
var is_first_point: bool = true
|
||||
var character: Texture = load(NO_CHARACTER_PATH)
|
||||
## The name of the picked character, its picture's file name, or empty when none is
|
||||
## picked. Saved with the quiz, so the picture can be found again after a restart.
|
||||
var character_name: String = ""
|
||||
|
||||
|
||||
func _init(new_name: String) -> void:
|
||||
@@ -28,6 +31,39 @@ func _init(new_name: String) -> void:
|
||||
player_score = 0
|
||||
|
||||
|
||||
## Shows [param texture], the character called [param new_name], for the player.
|
||||
func set_character(texture: Texture, new_name: String) -> void:
|
||||
character = texture
|
||||
character_name = new_name
|
||||
|
||||
|
||||
## Goes back to no character, e.g. when the characters come from somewhere else.
|
||||
func clear_character() -> void:
|
||||
character = load(NO_CHARACTER_PATH)
|
||||
character_name = ""
|
||||
|
||||
|
||||
## The player as a Dictionary that can be written as JSON, for [QuizSave].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"id": id, "name": player_name, "score": player_score,
|
||||
"is_first_point": is_first_point, "character": character_name,
|
||||
}
|
||||
|
||||
|
||||
## A player made from [param data], as given by [method to_dict], or null when
|
||||
## [param data] is not a player. The character's picture is not loaded here.
|
||||
static func from_dict(data: Variant) -> PlayerObject:
|
||||
if !(data is Dictionary) || !(data.get("name") is String) || !(data.get("id") is String):
|
||||
return null
|
||||
var player: PlayerObject = PlayerObject.new(data.name)
|
||||
player.id = data.id
|
||||
player.player_score = maxi(int(data.get("score", 0)), 0)
|
||||
player.is_first_point = bool(data.get("is_first_point", player.player_score == 0))
|
||||
player.character_name = str(data.get("character", ""))
|
||||
return player
|
||||
|
||||
|
||||
## Returns a random version 4 UUID, e.g. "3f2b8c1e-9a4d-4e7f-b2c6-5d8e1f0a7b93".
|
||||
static func _generate_uuid() -> String:
|
||||
var bytes: PackedByteArray = Crypto.new().generate_random_bytes(16)
|
||||
@@ -38,8 +74,12 @@ static func _generate_uuid() -> String:
|
||||
hex.substr(16, 4), hex.substr(20, 12)]
|
||||
|
||||
|
||||
## Gives the player a point. With a winning score, the point that reaches it wins; a
|
||||
## prepared quiz has none, so points just add up until every song has played.
|
||||
func add_point() -> void:
|
||||
if player_score < Settings.winning_score:
|
||||
var has_winning_score: bool = !Settings.is_prepared_quiz()
|
||||
if has_winning_score && player_score >= Settings.winning_score:
|
||||
return
|
||||
player_score += 1
|
||||
player_point_given.emit()
|
||||
var first_point: bool = player_score == 1 && is_first_point
|
||||
@@ -47,9 +87,9 @@ func add_point() -> void:
|
||||
is_first_point = false
|
||||
# With a low winning score the first point can also be match point or the win,
|
||||
# so the more important event is checked first
|
||||
if player_score == Settings.winning_score:
|
||||
if has_winning_score && player_score == Settings.winning_score:
|
||||
winner_triggered.emit()
|
||||
elif player_score == Settings.winning_score - 1:
|
||||
elif has_winning_score && player_score == Settings.winning_score - 1:
|
||||
match_point_triggered.emit()
|
||||
elif first_point:
|
||||
first_point_triggered.emit()
|
||||
|
||||
+83
-4
@@ -3,6 +3,13 @@ extends Node
|
||||
## played, answered and playing.
|
||||
##
|
||||
## Registered as the [code]Playlist[/code] autoload.
|
||||
##
|
||||
## Downloaded songs are kept on disk in the [code]songs[/code] folder next to the
|
||||
## settings, not in memory. The folder only holds the current playlist: when the game
|
||||
## starts, songs the saved quiz does not use are deleted (see [QuizSave]), and the folder
|
||||
## is emptied when the playlist is reset.
|
||||
|
||||
const SONGS_FOLDER_NAME: String = "songs"
|
||||
|
||||
var song_object_array: Array[SongObject] = []
|
||||
var latest_played_song: int = 0
|
||||
@@ -11,6 +18,17 @@ var first_song_downloaded: bool = false
|
||||
var first_song_played: bool = false
|
||||
var number_of_tracks_to_preload: int = 1
|
||||
var is_song_downloading: bool = false
|
||||
## The first song of the current game. Songs before it belong to earlier games, which
|
||||
## ended with Reset points.
|
||||
var game_start_song: int = 0
|
||||
|
||||
# How many song files have been written, so each gets a name of its own
|
||||
var _song_files_written: int = 0
|
||||
|
||||
|
||||
## The folder downloaded songs are kept in.
|
||||
static func songs_folder() -> String:
|
||||
return Settings.config_dir().path_join(SONGS_FOLDER_NAME)
|
||||
|
||||
|
||||
func get_next_song() -> SongObject:
|
||||
@@ -45,7 +63,7 @@ func song_is_answered(song_no: int = currently_playing_song) -> void:
|
||||
return
|
||||
GameLog.debug("show_answer_pressed song_object: ", song_object_array[song_no]._to_string())
|
||||
song_object_array[song_no].is_answered = true
|
||||
if Settings.add_to_stats:
|
||||
if Settings.add_to_stats && !Settings.is_offline():
|
||||
GameLog.debug("add to stats: ", song_no)
|
||||
MusicServerClient.mark_song_played(song_no)
|
||||
|
||||
@@ -68,7 +86,9 @@ func get_playlist() -> Array[SongObject]:
|
||||
func reset_playlist() -> void:
|
||||
currently_playing_song = -1
|
||||
latest_played_song = 0
|
||||
game_start_song = 0
|
||||
clear_song_list()
|
||||
clear_song_files()
|
||||
|
||||
|
||||
func clear_song_list() -> void:
|
||||
@@ -88,6 +108,10 @@ func get_current_soundtrack_title() -> String:
|
||||
return song_object_array[currently_playing_song].get_soundtrack_title()
|
||||
|
||||
|
||||
func get_current_soundtrack_uuid() -> String:
|
||||
return song_object_array[currently_playing_song].get_soundtrack_uuid()
|
||||
|
||||
|
||||
func get_current_song_title() -> String:
|
||||
return song_object_array[currently_playing_song].get_song_title()
|
||||
|
||||
@@ -100,18 +124,73 @@ func remove_point(player_id: String) -> void:
|
||||
song_object_array[currently_playing_song].remove_point(player_id)
|
||||
|
||||
|
||||
## Removes every player's points from every song, for a new game.
|
||||
func clear_points() -> void:
|
||||
for song_object: SongObject in song_object_array:
|
||||
song_object.players_given_point.clear()
|
||||
|
||||
|
||||
## Removes a player's points from every song.
|
||||
func remove_player_from_list(player_id: String) -> void:
|
||||
for song_object: SongObject in song_object_array:
|
||||
song_object.remove_point(player_id)
|
||||
|
||||
|
||||
## The song of [param song_no], read from its file. Returns null if the file is gone, so
|
||||
## the song can be downloaded again.
|
||||
func get_song(song_no: int) -> AudioStream:
|
||||
return song_object_array[song_no].song
|
||||
var song_object: SongObject = song_object_array[song_no]
|
||||
if song_object.song_path.is_empty():
|
||||
return song_object.song
|
||||
return AudioFile.load(song_object.song_path)
|
||||
|
||||
|
||||
func add_song_to_song_object(song_no: int, sound: AudioStream) -> void:
|
||||
song_object_array[song_no].song = sound
|
||||
func add_song_to_song_object(song_no: int, mp3: PackedByteArray) -> void:
|
||||
store_song(song_object_array[song_no], mp3)
|
||||
|
||||
|
||||
## Keeps a downloaded song in a file in [method songs_folder]. If the file can't be
|
||||
## written, the song is kept in memory instead.
|
||||
func store_song(song_object: SongObject, mp3: PackedByteArray) -> void:
|
||||
DirAccess.make_dir_recursive_absolute(songs_folder())
|
||||
# Songs kept from a saved quiz already have files, which must not be written over
|
||||
var path: String = songs_folder().path_join("song_%d.mp3" % _song_files_written)
|
||||
while FileAccess.file_exists(path):
|
||||
_song_files_written += 1
|
||||
path = songs_folder().path_join("song_%d.mp3" % _song_files_written)
|
||||
_song_files_written += 1
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
GameLog.warning("Could not save a song to ", path, ", keeping it in memory: ",
|
||||
error_string(FileAccess.get_open_error()))
|
||||
var sound: AudioStreamMP3 = AudioStreamMP3.new()
|
||||
sound.data = mp3
|
||||
song_object.song = sound
|
||||
song_object.song_path = ""
|
||||
return
|
||||
file.store_buffer(mp3)
|
||||
file.close()
|
||||
song_object.song = null
|
||||
song_object.song_path = path
|
||||
|
||||
|
||||
## Deletes the files of the songs downloaded so far, except those in [param keep].
|
||||
## The downloaded song files the playlist uses, which must be kept while it does.
|
||||
func song_files_in_use() -> PackedStringArray:
|
||||
var files: PackedStringArray = []
|
||||
for song_object: SongObject in song_object_array:
|
||||
if !song_object.song_path.is_empty():
|
||||
files.append(song_object.song_path)
|
||||
return files
|
||||
|
||||
|
||||
func clear_song_files(keep: PackedStringArray = []) -> void:
|
||||
if !DirAccess.dir_exists_absolute(songs_folder()):
|
||||
return
|
||||
for file_name: String in DirAccess.get_files_at(songs_folder()):
|
||||
var path: String = songs_folder().path_join(file_name)
|
||||
if !keep.has(path):
|
||||
DirAccess.remove_absolute(path)
|
||||
|
||||
|
||||
func song_has_played(song_no: int = latest_played_song) -> void:
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfabuhf854ukp
|
||||
@@ -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)]
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcvwgwhlu80qh
|
||||
+261
-3
@@ -3,38 +3,296 @@ extends Node
|
||||
##
|
||||
## Registered as the [code]Settings[/code] autoload.
|
||||
|
||||
## Emitted when another theme is used, for code that reads theme colours itself.
|
||||
signal theme_changed
|
||||
|
||||
## How the server picks a random song: any soundtrack then any of its songs, preferring
|
||||
## soundtracks played less than average, or every song with the same chance.
|
||||
enum RandomMode { NORMAL, LOW_PLAYED, CLASSIC }
|
||||
|
||||
const MAX_NUMBER_OF_PLAYERS: int = 7
|
||||
## How many letters a search needs before it also finds names with the letters in order
|
||||
## but apart, e.g. "lttp" for "A Link to the Past". Shorter searches only find names
|
||||
## that contain the text, since a letter or two in order matches most of the library.
|
||||
const LETTERS_FOR_LOOSE_SEARCH: int = 3
|
||||
## The MusicServers that can always be picked in the settings.
|
||||
const SERVERS: Array[String] = [
|
||||
"https://music.sanplex.xyz", "https://tmusic.sanplex.xyz", "https://rmusic.sanplex.xyz",
|
||||
"https://old-music.sanplex.xyz", "http://localhost:8080",
|
||||
]
|
||||
## The server debug mode uses.
|
||||
const DEBUG_SERVER: String = "http://localhost:8080"
|
||||
## The winning score debug mode starts with, so a game is quick to win while testing.
|
||||
const DEBUG_WINNING_SCORE: int = 5
|
||||
## The settings the game saves by itself, by section, in [method saved_settings_path].
|
||||
## Count plays in statistics and Prefer less played soundtracks are left out on purpose.
|
||||
const SAVED_SETTINGS: Dictionary[String, Array] = {
|
||||
"quiz": [
|
||||
&"auto_repeat_song", &"stop_after_current", &"hide_next_track", &"hide_beginning",
|
||||
&"hide_for_seconds", &"hide_length", &"hide_ticks", &"winning_score",
|
||||
&"inspiration_list_speed", &"short_songs", &"short_song_seconds",
|
||||
],
|
||||
"window": [&"fullscreen"],
|
||||
"app": [&"last_seen_version", &"theme_id", &"reduce_animations"],
|
||||
}
|
||||
## The looks the game can have, picked in Settings: an id that is saved, the name shown
|
||||
## and the theme file. The first is the default. See themes/README.md.
|
||||
const THEMES: Array[Dictionary] = [
|
||||
{"id": "classic", "name": "Classic", "path": "res://themes/main_theme.tres"},
|
||||
{"id": "original_web", "name": "Original web", "path": "res://themes/original_web_theme.tres"},
|
||||
{"id": "nord", "name": "Nord", "path": "res://themes/nord_theme.tres"},
|
||||
{"id": "christmas", "name": "Christmas", "path": "res://themes/christmas_theme.tres"},
|
||||
]
|
||||
## When this environment variable is set, the settings files are kept in that folder
|
||||
## instead of the user's config folder. The tests set it so they never touch the real files.
|
||||
const CONFIG_DIR_ENV: String = "MUSICPLAYER_CONFIG_DIR"
|
||||
## The file the user edits by hand. The game writes it only when it is missing, with every
|
||||
## setting commented out.
|
||||
const CONFIG_FILE_NAME: String = "config.ini"
|
||||
const CONFIG_TEMPLATE: String = """# MusicPlayer config. The game never changes this file, so edit it as you like.
|
||||
# Lines starting with # or ; are comments. Remove the # in front of a setting to use it,
|
||||
# and put it back to go back to the default.
|
||||
|
||||
[server]
|
||||
# The MusicServer to use when the game starts. Another one can still be picked in
|
||||
# Settings for as long as the game runs. Debug mode uses http://localhost:8080.
|
||||
#url = https://music.sanplex.xyz
|
||||
|
||||
# Password for getting an API token from the server.
|
||||
#password =
|
||||
|
||||
[game]
|
||||
# true or false. Debug mode uses the local server, starts with a winning score of 5 and
|
||||
# shows DEBUG in the top bar. Without this setting, the game is in debug mode when run
|
||||
# from the Godot editor and not when exported.
|
||||
#debug = true
|
||||
"""
|
||||
|
||||
var default_path: String = "https://music.sanplex.xyz"
|
||||
## Password for getting an API token from the server. Any password works when the
|
||||
## server has none set.
|
||||
var api_password: String = ""
|
||||
## The servers that can be picked in Settings: [constant SERVERS], plus the server from
|
||||
## the config file when it is not one of them.
|
||||
var servers: Array[String] = SERVERS.duplicate()
|
||||
var selected_server: int = 0
|
||||
var is_local: bool = false
|
||||
var is_debug: bool = false
|
||||
var stop_after_current: bool = true
|
||||
var auto_repeat_song: bool = true
|
||||
var hide_next_track: bool = true
|
||||
var add_to_stats: bool = true
|
||||
var use_low_played_mode: bool = false
|
||||
## How the server picks random songs. Not saved, like statistics counting.
|
||||
var random_mode: RandomMode = RandomMode.NORMAL
|
||||
var winning_score: int = 20
|
||||
var fullscreen: bool = false
|
||||
var play_local: bool = false
|
||||
var inspiration_list_speed: int = 1
|
||||
var hide_for_seconds: float = 5.0
|
||||
var hide_beginning: bool = false
|
||||
var hide_length: bool = false
|
||||
var hide_ticks: bool = false
|
||||
## Plays only [member short_song_seconds] of each song, from a random point, so more
|
||||
## songs fit in a game.
|
||||
var short_songs: bool = false
|
||||
var short_song_seconds: int = 30
|
||||
var player_array: Array[PlayerObject]
|
||||
var edit_players: bool = false
|
||||
var currently_syncing: bool = false
|
||||
var character_select_open: bool = false
|
||||
## The ID of the game's play queue on each server, by server URL, set by
|
||||
## [code]MusicServerClient[/code]. Saved in the [code][queues][/code] section of
|
||||
## [method saved_settings_path], so the game goes on with the same queue after a restart.
|
||||
var queue_ids: Dictionary[String, String] = {}
|
||||
## The version the game last showed the About popup for. When it is not [member version],
|
||||
## this version is new and the main window opens the About popup to show what is new.
|
||||
var last_seen_version: String = ""
|
||||
## Turns every animation off, so nothing moves. See [Anim].
|
||||
var reduce_animations: bool = false
|
||||
## The id of the theme in use, one of [constant THEMES].
|
||||
var theme_id: String = THEMES[0].id
|
||||
## The folder songs are played from instead of the server, picked in the settings, or null
|
||||
## to use the server. Kept for this session only.
|
||||
var music_folder: MusicFolder = null
|
||||
## The folder characters are read from instead of the server, picked in the settings, or
|
||||
## empty. Kept for this session only. See [method characters_dir].
|
||||
var character_folder: String = ""
|
||||
|
||||
## The game version, set in Project Settings > Application > Config > Version.
|
||||
## Releases are made from it, see RELEASING.md.
|
||||
var version: String = ProjectSettings.get_setting("application/config/version", "")
|
||||
|
||||
|
||||
# Autoloads are ready before any scene, so the server and debug mode from the config file
|
||||
# are used from the very first request. The saved settings come after, so the debug
|
||||
# winning score is only used until another one is saved.
|
||||
func _ready() -> void:
|
||||
apply_config(load_config())
|
||||
load_saved_settings()
|
||||
_use_theme(theme_id)
|
||||
if fullscreen:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
|
||||
|
||||
|
||||
## Uses the server, password and debug mode from the hand-edited [param config]. Settings
|
||||
## left out of it get their defaults.
|
||||
func apply_config(config: IniFile) -> void:
|
||||
is_debug = config.get_bool("game", "debug", OS.has_feature("debug"))
|
||||
default_path = DEBUG_SERVER if is_debug else SERVERS[0]
|
||||
if is_debug:
|
||||
winning_score = DEBUG_WINNING_SCORE
|
||||
var url: String = config.get_string("server", "url")
|
||||
if !url.is_empty():
|
||||
default_path = url.trim_suffix("/")
|
||||
if !servers.has(default_path):
|
||||
servers.append(default_path)
|
||||
selected_server = servers.find(default_path)
|
||||
api_password = config.get_string("server", "password")
|
||||
|
||||
|
||||
## The folder with the settings files: a MusicPlayer folder in the user's config folder,
|
||||
## e.g. [code]~/.config[/code] on Linux and [code]%APPDATA%[/code] on Windows.
|
||||
func config_dir() -> String:
|
||||
var dir: String = OS.get_environment(CONFIG_DIR_ENV)
|
||||
if dir.is_empty():
|
||||
dir = OS.get_config_dir().path_join("MusicPlayer")
|
||||
return dir
|
||||
|
||||
|
||||
## Where the hand-edited config file is.
|
||||
func config_path() -> String:
|
||||
return config_dir().path_join(CONFIG_FILE_NAME)
|
||||
|
||||
|
||||
## Where the game saves its settings. Debug mode uses a file of its own, so testing never
|
||||
## changes the real settings.
|
||||
func saved_settings_path() -> String:
|
||||
var file_name: String = "settings_debug.cfg" if is_debug else "settings.cfg"
|
||||
return config_dir().path_join(file_name)
|
||||
|
||||
|
||||
## Reads the hand-edited config file. When it is missing, writes one with every setting
|
||||
## commented out, so there is a file to edit.
|
||||
func load_config() -> IniFile:
|
||||
var path: String = config_path()
|
||||
if !FileAccess.file_exists(path):
|
||||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||
var template: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||
if template != null:
|
||||
template.store_string(CONFIG_TEMPLATE)
|
||||
return IniFile.load_file(path)
|
||||
|
||||
|
||||
## Loads the settings the game saved. Settings missing from the file, or saved with the
|
||||
## wrong type, keep their current value.
|
||||
func load_saved_settings() -> void:
|
||||
var file: ConfigFile = ConfigFile.new()
|
||||
if file.load(saved_settings_path()) != OK:
|
||||
return
|
||||
for section: String in SAVED_SETTINGS:
|
||||
for setting: StringName in SAVED_SETTINGS[section]:
|
||||
set(setting, _saved_value(file, section, setting, get(setting)))
|
||||
if file.has_section("queues"):
|
||||
queue_ids.clear()
|
||||
for server: String in file.get_section_keys("queues"):
|
||||
var id: Variant = file.get_value("queues", server)
|
||||
if id is String:
|
||||
queue_ids[server] = id
|
||||
|
||||
|
||||
## Saves the settings in [constant SAVED_SETTINGS], so they are the same the next time
|
||||
## the game starts.
|
||||
func save_settings() -> void:
|
||||
var path: String = saved_settings_path()
|
||||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||
var file: ConfigFile = ConfigFile.new()
|
||||
for section: String in SAVED_SETTINGS:
|
||||
for setting: StringName in SAVED_SETTINGS[section]:
|
||||
file.set_value(section, setting, get(setting))
|
||||
for server: String in queue_ids:
|
||||
file.set_value("queues", server, queue_ids[server])
|
||||
var error: Error = file.save(path)
|
||||
if error != OK:
|
||||
GameLog.warning("Could not save the settings to %s: %s" % [path, error_string(error)])
|
||||
|
||||
|
||||
## Sets the play queue ID for [param server] and saves it. Only the ID is changed in
|
||||
## the file; the other settings in it stay as they were saved.
|
||||
func set_queue_id(server: String, id: String) -> void:
|
||||
queue_ids[server] = id
|
||||
var path: String = saved_settings_path()
|
||||
var file: ConfigFile = ConfigFile.new()
|
||||
# A missing file is fine: it is written with only the queue ID
|
||||
file.load(path)
|
||||
file.set_value("queues", server, id)
|
||||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||
var error: Error = file.save(path)
|
||||
if error != OK:
|
||||
GameLog.warning("Could not save the queue ID to %s: %s" % [path, error_string(error)])
|
||||
|
||||
|
||||
## Turns fullscreen on or off and saves it.
|
||||
func set_fullscreen(on: bool) -> void:
|
||||
fullscreen = on
|
||||
if on:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
|
||||
else:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
|
||||
save_settings()
|
||||
|
||||
|
||||
# The saved value of [param key], or [param default] when it is missing or of another type
|
||||
func _saved_value(file: ConfigFile, section: String, key: String, default: Variant) -> Variant:
|
||||
var value: Variant = file.get_value(section, key, default)
|
||||
if typeof(value) != typeof(default):
|
||||
push_warning("%s in [%s] should be a %s, not %s" % [key, section,
|
||||
type_string(typeof(default)), var_to_str(value)])
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
## Whether songs come from a music folder instead of the server. Nothing but the
|
||||
## characters is asked from the server then.
|
||||
func is_offline() -> bool:
|
||||
return music_folder != null
|
||||
|
||||
|
||||
## Whether a prepared quiz is played from a music folder. It has no winning score: the
|
||||
## game ends when every song has played.
|
||||
func is_prepared_quiz() -> bool:
|
||||
return music_folder != null && music_folder.kind == MusicFolder.Kind.PREPARED_QUIZ
|
||||
|
||||
|
||||
## The folder the characters come from, or empty for the server's: the folder picked in
|
||||
## the settings, otherwise the [code]characters[/code] folder of the music folder played.
|
||||
func characters_dir() -> String:
|
||||
if !character_folder.is_empty():
|
||||
return character_folder
|
||||
if music_folder != null:
|
||||
return music_folder.characters_path
|
||||
return ""
|
||||
|
||||
|
||||
## Switches the whole game to the theme [param id] from [constant THEMES] and saves the
|
||||
## choice. Controls without a theme of their own take it from the root window.
|
||||
func apply_theme(id: String) -> void:
|
||||
_use_theme(id)
|
||||
save_settings()
|
||||
|
||||
|
||||
## The theme in [constant THEMES] with [param id], or the default one if there is none.
|
||||
func theme_entry(id: String) -> Dictionary:
|
||||
for entry: Dictionary in THEMES:
|
||||
if entry.id == id:
|
||||
return entry
|
||||
return THEMES[0]
|
||||
|
||||
|
||||
func _use_theme(id: String) -> void:
|
||||
var entry: Dictionary = theme_entry(id)
|
||||
theme_id = entry.id
|
||||
get_tree().root.theme = load(entry.path)
|
||||
theme_changed.emit()
|
||||
|
||||
|
||||
func delete_children(node: Node) -> void:
|
||||
for n: Node in node.get_children():
|
||||
node.remove_child(n)
|
||||
|
||||
+55
-5
@@ -5,6 +5,8 @@ class_name SongObject
|
||||
var song_number: int
|
||||
var song_title: String
|
||||
var soundtrack_title: String
|
||||
## The UUID of the song's soundtrack on the server, empty for songs from a music folder.
|
||||
var soundtrack_uuid: String = ""
|
||||
|
||||
## Ids of the players who got a point for this song.
|
||||
var players_given_point: Array[String] = []
|
||||
@@ -12,7 +14,14 @@ var players_given_point: Array[String] = []
|
||||
var is_answered: bool = false
|
||||
var has_played: bool = false
|
||||
var is_playing: bool = false
|
||||
## The song's file: a download in [method Playlist.songs_folder], or a song in the music
|
||||
## folder. Read the song with [method Playlist.get_song].
|
||||
var song_path: String = ""
|
||||
## The song itself, only when it could not be saved to a file.
|
||||
var song: AudioStream
|
||||
## Where the part played with Short songs starts, in seconds, or -1 before it is picked.
|
||||
## Picked once, so restarting and repeating play the same part.
|
||||
var short_start: float = -1.0
|
||||
|
||||
|
||||
func get_soundtrack_title() -> String:
|
||||
@@ -29,18 +38,28 @@ func get_song_title() -> String:
|
||||
return "??????"
|
||||
|
||||
|
||||
## The soundtrack's UUID once the answer is shown, otherwise empty.
|
||||
func get_soundtrack_uuid() -> String:
|
||||
return soundtrack_uuid if is_answered else ""
|
||||
|
||||
|
||||
func get_song_info() -> String:
|
||||
if Settings.is_debug:
|
||||
var format_string: String = "%d. %s - %s | played: %s | answered: %s"
|
||||
return format_string % [(song_number + 1), soundtrack_title, song_title, has_played,
|
||||
is_answered]
|
||||
return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(),
|
||||
has_played, is_answered]
|
||||
elif is_answered:
|
||||
var format_string: String = "%d. %s - %s"
|
||||
return format_string % [(song_number + 1), soundtrack_title, song_title]
|
||||
return "%d. %s" % [(song_number + 1), _full_title()]
|
||||
else:
|
||||
return "??? - ???"
|
||||
|
||||
|
||||
# "Soundtrack - Song", or only the song in a prepared quiz, which has no soundtracks
|
||||
func _full_title() -> String:
|
||||
if soundtrack_title.is_empty():
|
||||
return song_title
|
||||
return "%s - %s" % [soundtrack_title, song_title]
|
||||
|
||||
|
||||
func add_point(player_id: String) -> void:
|
||||
GameLog.debug("add_point")
|
||||
if !players_given_point.has(player_id):
|
||||
@@ -52,6 +71,37 @@ func remove_point(player_id: String) -> void:
|
||||
players_given_point.erase(player_id)
|
||||
|
||||
|
||||
## The song as a Dictionary that can be written as JSON, for [QuizSave]. A song kept only
|
||||
## in memory is saved without its file.
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"number": song_number, "soundtrack": soundtrack_title, "song": song_title,
|
||||
"points": players_given_point.duplicate(), "answered": is_answered,
|
||||
"played": has_played, "playing": is_playing, "path": song_path,
|
||||
"soundtrack_uuid": soundtrack_uuid,
|
||||
}
|
||||
|
||||
|
||||
## A song made from [param data], as given by [method to_dict], or null when [param data]
|
||||
## is not a song.
|
||||
static func from_dict(data: Variant) -> SongObject:
|
||||
if !(data is Dictionary) || !(data.get("number") is float || data.get("number") is int):
|
||||
return null
|
||||
var song_object: SongObject = SongObject.new()
|
||||
song_object.song_number = int(data.number)
|
||||
song_object.soundtrack_title = str(data.get("soundtrack", ""))
|
||||
song_object.song_title = str(data.get("song", ""))
|
||||
song_object.soundtrack_uuid = str(data.get("soundtrack_uuid", ""))
|
||||
if data.get("points") is Array:
|
||||
for player_id: Variant in data.points:
|
||||
song_object.add_point(str(player_id))
|
||||
song_object.is_answered = bool(data.get("answered", false))
|
||||
song_object.has_played = bool(data.get("played", false))
|
||||
song_object.is_playing = bool(data.get("playing", false))
|
||||
song_object.song_path = str(data.get("path", ""))
|
||||
return song_object
|
||||
|
||||
|
||||
func _to_string() -> String:
|
||||
var format_string: String = "song_number: %d | soundtrack_title: %s | song_title: %s" \
|
||||
+ " | is_answered: %s | has_played: %s | is_playing: %s"
|
||||
|
||||
+3
-263
@@ -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=""
|
||||
|
||||
@@ -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.
@@ -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={}
|
||||
@@ -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.
@@ -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={}
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
+42
-22
@@ -11,6 +11,8 @@
|
||||
[ext_resource type="PackedScene" uid="uid://bijh5h5yrivm3" path="res://popups/LogPopup.tscn" id="14_26rwn"]
|
||||
[ext_resource type="PackedScene" path="res://statistics/StatisticsView.tscn" id="statistics_view_scene"]
|
||||
[ext_resource type="PackedScene" path="res://popups/SoundtrackStatsPopup.tscn" id="soundtrack_stats_popup_scene"]
|
||||
[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://popups/SettingsPopup.tscn" id="settings_popup_scene"]
|
||||
[ext_resource type="PackedScene" path="res://popups/UpdatePopup.tscn" id="update_popup_scene"]
|
||||
|
||||
[node name="MainWindow" type="Control" unique_id=407149696]
|
||||
layout_mode = 3
|
||||
@@ -42,55 +44,64 @@ layout_mode = 1
|
||||
[node name="SearchPopup" parent="." unique_id=1661250375 instance=ExtResource("9_5ijvr")]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 56.0
|
||||
offset_top = 96.0
|
||||
offset_right = 56.0
|
||||
offset_bottom = 96.0
|
||||
offset_left = 384.0
|
||||
offset_top = 100.0
|
||||
offset_right = 384.0
|
||||
offset_bottom = 100.0
|
||||
|
||||
[node name="SyncPopup" parent="." unique_id=182499699 instance=ExtResource("10_yxw0b")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 210.0
|
||||
offset_top = 153.0
|
||||
offset_top = 220.0
|
||||
offset_right = 210.0
|
||||
offset_bottom = 153.0
|
||||
offset_bottom = 220.0
|
||||
|
||||
[node name="QRPopup" parent="." unique_id=589733821 instance=ExtResource("15_qr_window")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 720.0
|
||||
offset_top = 153.0
|
||||
offset_right = 720.0
|
||||
offset_bottom = 153.0
|
||||
offset_left = 735.0
|
||||
offset_top = 270.0
|
||||
offset_right = 735.0
|
||||
offset_bottom = 270.0
|
||||
|
||||
[node name="ShortcutsPopup" parent="." unique_id=1513416242 instance=ExtResource("13_8eebo")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 562.0
|
||||
offset_top = 160.0
|
||||
offset_right = 1262.0
|
||||
offset_bottom = 860.0
|
||||
offset_left = 610.0
|
||||
offset_top = 187.0
|
||||
offset_right = 610.0
|
||||
offset_bottom = 187.0
|
||||
|
||||
[node name="AboutPopup" parent="." unique_id=141261008 instance=ExtResource("16_about_window")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 720.0
|
||||
offset_top = 153.0
|
||||
offset_right = 720.0
|
||||
offset_bottom = 153.0
|
||||
offset_left = 360.0
|
||||
offset_top = 220.0
|
||||
offset_right = 360.0
|
||||
offset_bottom = 220.0
|
||||
|
||||
[node name="LogPopup" parent="." unique_id=1289881388 instance=ExtResource("14_26rwn")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 562.0
|
||||
offset_top = 132.0
|
||||
offset_right = 1262.0
|
||||
offset_bottom = 832.0
|
||||
offset_left = 610.0
|
||||
offset_top = 190.0
|
||||
offset_right = 1310.0
|
||||
offset_bottom = 890.0
|
||||
|
||||
[node name="SettingsPopup" parent="." unique_id=1323246978 instance=ExtResource("settings_popup_scene")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 700.0
|
||||
offset_top = 405.0
|
||||
offset_right = 700.0
|
||||
offset_bottom = 405.0
|
||||
|
||||
[node name="SoundtrackStatsPopup" parent="." unique_id=1213999001 instance=ExtResource("soundtrack_stats_popup_scene")]
|
||||
visible = false
|
||||
@@ -99,3 +110,12 @@ offset_left = 610.0
|
||||
offset_top = 140.0
|
||||
offset_right = 610.0
|
||||
offset_bottom = 140.0
|
||||
|
||||
[node name="UpdatePopup" parent="." instance=ExtResource("update_popup_scene")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 640.0
|
||||
offset_top = 390.0
|
||||
offset_right = 640.0
|
||||
offset_bottom = 390.0
|
||||
+83
-47
@@ -2,14 +2,6 @@
|
||||
|
||||
[ext_resource type="Script" path="res://main/top_bar.gd" id="top_bar_script"]
|
||||
|
||||
[sub_resource type="InputEventKey" id="InputEventKey_ujjlu"]
|
||||
device = -1
|
||||
alt_pressed = true
|
||||
keycode = 83
|
||||
|
||||
[sub_resource type="Shortcut" id="Shortcut_fbju4"]
|
||||
events = [SubResource("InputEventKey_ujjlu")]
|
||||
|
||||
[sub_resource type="ButtonGroup" id="ButtonGroup_tabs"]
|
||||
|
||||
[node name="TopBar" type="Control" unique_id=1489162753]
|
||||
@@ -25,6 +17,7 @@ script = ExtResource("top_bar_script")
|
||||
layout_mode = 0
|
||||
offset_right = 1920.0
|
||||
offset_bottom = 56.0
|
||||
theme_type_variation = &"TopBarBackground"
|
||||
|
||||
[node name="Title" type="Label" parent="." unique_id=1187907861]
|
||||
layout_mode = 0
|
||||
@@ -36,63 +29,105 @@ size_flags_horizontal = 0
|
||||
theme_type_variation = &"Title"
|
||||
text = "Music Player Randomizer"
|
||||
|
||||
[node name="SyncButton" type="Button" parent="." unique_id=1228313556]
|
||||
[node name="DebugLabel" type="Label" parent="." unique_id=1959221257]
|
||||
visible = false
|
||||
layout_mode = 0
|
||||
offset_left = 1442.0
|
||||
offset_top = 8.0
|
||||
offset_right = 1512.0
|
||||
offset_bottom = 48.0
|
||||
offset_left = 384.0
|
||||
offset_top = 12.0
|
||||
offset_right = 470.0
|
||||
offset_bottom = 50.0
|
||||
theme_type_variation = &"HighlightLarge"
|
||||
text = "DEBUG"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="UpdateBox" type="HBoxContainer" parent="." unique_id=1959221258]
|
||||
visible = false
|
||||
layout_mode = 0
|
||||
offset_left = 1096.0
|
||||
offset_top = 12.0
|
||||
offset_right = 1432.0
|
||||
offset_bottom = 50.0
|
||||
mouse_filter = 2
|
||||
alignment = 2
|
||||
|
||||
[node name="UpdateButton" type="Button" parent="UpdateBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_type_variation = &"UpdateButton"
|
||||
text = "Version 0.0.0 is available"
|
||||
text_overrun_behavior = 3
|
||||
clip_text = true
|
||||
alignment = 2
|
||||
|
||||
[node name="OfflineLabel" type="Label" parent="." unique_id=1959221259]
|
||||
visible = false
|
||||
layout_mode = 0
|
||||
offset_left = 1096.0
|
||||
offset_top = 12.0
|
||||
offset_right = 1432.0
|
||||
offset_bottom = 50.0
|
||||
mouse_filter = 1
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Offline: Music"
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="Buttons" type="HBoxContainer" parent="."]
|
||||
layout_mode = 0
|
||||
offset_left = 1200.0
|
||||
offset_top = 10.0
|
||||
offset_right = 1856.0
|
||||
offset_bottom = 46.0
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 2
|
||||
|
||||
[node name="SyncButton" type="Button" parent="Buttons" unique_id=1228313556]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Sync"
|
||||
|
||||
[node name="SearchButton" type="Button" parent="." unique_id=1782453581]
|
||||
layout_mode = 0
|
||||
offset_left = 1354.0
|
||||
offset_top = 8.0
|
||||
offset_right = 1424.0
|
||||
offset_bottom = 48.0
|
||||
[node name="SettingsButton" type="Button" parent="Buttons" unique_id=1228313557]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
shortcut = SubResource("Shortcut_fbju4")
|
||||
text = "Search"
|
||||
text = "Settings"
|
||||
|
||||
[node name="ShortcutsButton" type="Button" parent="." unique_id=2066498226]
|
||||
layout_mode = 0
|
||||
offset_left = 1626.0
|
||||
offset_top = 8.0
|
||||
offset_right = 1696.0
|
||||
offset_bottom = 48.0
|
||||
[node name="ShortcutsButton" type="Button" parent="Buttons" unique_id=2066498226]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Shortcuts"
|
||||
|
||||
[node name="AboutButton" type="Button" parent="." unique_id=1057035253]
|
||||
layout_mode = 0
|
||||
offset_left = 1722.0
|
||||
offset_top = 8.0
|
||||
offset_right = 1792.0
|
||||
offset_bottom = 48.0
|
||||
[node name="AboutButton" type="Button" parent="Buttons" unique_id=1057035253]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "About"
|
||||
|
||||
[node name="QRButton" type="Button" parent="." unique_id=2021908559]
|
||||
layout_mode = 0
|
||||
offset_left = 1802.0
|
||||
offset_top = 8.0
|
||||
offset_right = 1872.0
|
||||
offset_bottom = 48.0
|
||||
[node name="QRButton" type="Button" parent="Buttons" unique_id=2021908559]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "QR"
|
||||
|
||||
[node name="QuizTab" type="Button" parent="." unique_id=1903412671]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 840.0
|
||||
offset_top = 8.0
|
||||
offset_right = 955.0
|
||||
offset_bottom = 48.0
|
||||
offset_left = 839.0
|
||||
offset_top = 10.0
|
||||
offset_right = 954.0
|
||||
offset_bottom = 46.0
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"TabButton"
|
||||
toggle_mode = true
|
||||
@@ -102,11 +137,12 @@ button_group = SubResource("ButtonGroup_tabs")
|
||||
text = "Quiz"
|
||||
|
||||
[node name="StatisticsTab" type="Button" parent="." unique_id=1903412672]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 965.0
|
||||
offset_top = 8.0
|
||||
offset_right = 1080.0
|
||||
offset_bottom = 48.0
|
||||
offset_left = 966.0
|
||||
offset_top = 10.0
|
||||
offset_right = 1081.0
|
||||
offset_bottom = 46.0
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"TabButton"
|
||||
toggle_mode = true
|
||||
|
||||
+79
-7
@@ -5,6 +5,9 @@ extends Control
|
||||
##
|
||||
## Everything that is not part of a quiz lives here.
|
||||
|
||||
## How far a tab slides in from the side when it is chosen.
|
||||
const TAB_SLIDE: float = 60.0
|
||||
|
||||
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
|
||||
var popup_backdrop: PopupBackdrop
|
||||
|
||||
@@ -16,39 +19,95 @@ var popup_backdrop: PopupBackdrop
|
||||
@onready var qr_popup: QRPopup = %QRPopup
|
||||
@onready var about_popup: AboutPopup = %AboutPopup
|
||||
@onready var log_popup: LogPopup = %LogPopup
|
||||
@onready var settings_popup: SettingsPopup = %SettingsPopup
|
||||
@onready var update_popup: UpdatePopup = %UpdatePopup
|
||||
@onready var statistics_view: StatisticsView = $StatisticsView
|
||||
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
popup_backdrop = PopupBackdrop.add_to(self, _popups())
|
||||
# Every button grows a little under the mouse and shrinks when clicked
|
||||
ButtonBounce.add_to_every_button(get_tree())
|
||||
if OS.has_feature("macos"):
|
||||
GameLog.debug("is_mac")
|
||||
top_bar.search_pressed.connect(show_search)
|
||||
quiz.search_pressed.connect(show_search)
|
||||
top_bar.sync_pressed.connect(sync_soundtracks)
|
||||
top_bar.tab_selected.connect(show_tab)
|
||||
top_bar.shortcuts_pressed.connect(show_shortcuts)
|
||||
top_bar.about_pressed.connect(show_about)
|
||||
top_bar.qr_pressed.connect(show_qr)
|
||||
top_bar.settings_pressed.connect(settings_popup.open)
|
||||
top_bar.update_pressed.connect(update_popup.show_update)
|
||||
update_popup.restarting.connect(quiz.save_now)
|
||||
settings_popup.server_changed.connect(quiz.on_server_changed)
|
||||
settings_popup.characters_changed.connect(quiz.character_select_popup.load_characters)
|
||||
settings_popup.clear_cache_requested.connect(clear_cache)
|
||||
settings_popup.visibility_changed.connect(_on_settings_popup_visibility_changed)
|
||||
|
||||
sync_popup.sync_started.connect(_on_sync_started)
|
||||
sync_popup.sync_finished.connect(_on_sync_finished)
|
||||
quiz.server_changed.connect(search_popup.get_list_of_soundtracks)
|
||||
quiz.server_changed.connect(_on_music_source_changed)
|
||||
quiz.event_logged.connect(log_popup.add_log_row)
|
||||
statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack)
|
||||
soundtrack_stats_popup.covers = statistics_view.covers
|
||||
quiz.now_playing.covers = statistics_view.covers
|
||||
search_popup.soundtracks_loaded.connect(_remember_cover_times)
|
||||
# New alternative names are found by the statistics search and the Search popup
|
||||
soundtrack_stats_popup.alternative_names_changed.connect(
|
||||
statistics_view.update_alternative_names)
|
||||
soundtrack_stats_popup.alternative_names_changed.connect(
|
||||
func(_soundtrack: Soundtrack) -> void: search_popup.get_list_of_soundtracks())
|
||||
soundtrack_stats_popup.cover_changed.connect(statistics_view.update_cover)
|
||||
|
||||
# An update leaves the old version next to the game, to delete once the new one runs
|
||||
if !OS.has_feature("editor"):
|
||||
Updater.remove_old_files(OS.get_executable_path())
|
||||
# A saved quiz may have been played from a music folder
|
||||
top_bar.update_offline_state()
|
||||
search_popup.get_list_of_soundtracks()
|
||||
show_about_if_new_version()
|
||||
|
||||
|
||||
## Deletes the cached character pictures and covers. The characters are loaded again at
|
||||
## once, so the character select has its pictures; covers come when they are shown.
|
||||
func clear_cache() -> void:
|
||||
quiz.character_select_popup.character_cache.clear()
|
||||
statistics_view.covers.clear()
|
||||
quiz.character_select_popup.load_characters()
|
||||
|
||||
|
||||
# The quiz finds a soundtrack's cover by the cover times in the soundtrack list
|
||||
func _remember_cover_times(soundtracks: Array[Soundtrack]) -> void:
|
||||
var times: Dictionary = {}
|
||||
for soundtrack: Soundtrack in soundtracks:
|
||||
times[soundtrack.uuid] = soundtrack.cover_modified
|
||||
statistics_view.covers.set_cover_times(times)
|
||||
|
||||
|
||||
# After another server or a music folder was picked. Statistics need the server.
|
||||
func _on_music_source_changed() -> void:
|
||||
top_bar.update_offline_state()
|
||||
top_bar.check_for_update()
|
||||
if Settings.is_offline() && statistics_view.visible:
|
||||
show_tab(&"quiz")
|
||||
|
||||
|
||||
## Opens the About popup, with what is new, the first time this version starts.
|
||||
func show_about_if_new_version() -> void:
|
||||
if Settings.last_seen_version == Settings.version:
|
||||
return
|
||||
Settings.last_seen_version = Settings.version
|
||||
Settings.save_settings()
|
||||
show_about()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed:
|
||||
if event.alt_pressed && event.keycode == KEY_ENTER:
|
||||
GameLog.debug("Alt + Enter pressed")
|
||||
Settings.fullscreen = !Settings.fullscreen
|
||||
if Settings.fullscreen:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
|
||||
if !Settings.fullscreen:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
|
||||
Settings.set_fullscreen(!Settings.fullscreen)
|
||||
if event.alt_pressed && event.keycode == KEY_K:
|
||||
shortcuts_popup.toggle()
|
||||
if event.alt_pressed && event.keycode == KEY_L:
|
||||
@@ -57,7 +116,14 @@ func _input(event: InputEvent) -> void:
|
||||
|
||||
func _popups() -> Array[BasePopup]:
|
||||
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
|
||||
soundtrack_stats_popup]
|
||||
soundtrack_stats_popup, settings_popup, update_popup]
|
||||
|
||||
|
||||
# How many songs to download ahead can be changed in the settings. Waiting until the
|
||||
# settings are closed avoids starting downloads while the number is clicked up and down.
|
||||
func _on_settings_popup_visibility_changed() -> void:
|
||||
if !settings_popup.visible:
|
||||
quiz.song_loader.fill_cache()
|
||||
|
||||
|
||||
func show_search() -> void:
|
||||
@@ -85,8 +151,14 @@ func show_qr() -> void:
|
||||
func show_tab(tab: StringName) -> void:
|
||||
GameLog.debug("show_tab ", tab)
|
||||
top_bar.select_tab(tab)
|
||||
var was_statistics: bool = statistics_view.visible
|
||||
statistics_view.visible = tab == &"statistics"
|
||||
quiz.visible = !statistics_view.visible
|
||||
# The tab that comes in slides from its side: statistics from the right
|
||||
if statistics_view.visible && !was_statistics:
|
||||
Anim.slide_in(statistics_view, Vector2(TAB_SLIDE, 0))
|
||||
elif quiz.visible && was_statistics:
|
||||
Anim.slide_in(quiz, Vector2(-TAB_SLIDE, 0))
|
||||
if statistics_view.visible:
|
||||
statistics_view.refresh()
|
||||
|
||||
|
||||
+93
-8
@@ -2,28 +2,113 @@ class_name TopBar
|
||||
extends Control
|
||||
## The bar along the top of the main window, with the title, the Quiz and Statistics tabs
|
||||
## and the buttons for things that are not part of a quiz.
|
||||
##
|
||||
## With a music folder picked, the buttons that need the server are turned off and the
|
||||
## folder's name is shown.
|
||||
|
||||
## Shown on the buttons that need the server while a music folder is used.
|
||||
const OFFLINE_TOOLTIP: String = "Not available with a music folder"
|
||||
|
||||
signal search_pressed
|
||||
signal sync_pressed
|
||||
signal settings_pressed
|
||||
signal shortcuts_pressed
|
||||
signal about_pressed
|
||||
signal qr_pressed
|
||||
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
|
||||
signal tab_selected(tab: StringName)
|
||||
## Emitted when "Version X is available" is clicked, with the newest version.
|
||||
signal update_pressed(latest_version: String)
|
||||
|
||||
@onready var sync_button: Button = $SyncButton
|
||||
@onready var quiz_tab: Button = $QuizTab
|
||||
@onready var statistics_tab: Button = $StatisticsTab
|
||||
## Space between the message in the middle and the tabs and buttons around it.
|
||||
const MIDDLE_GAP: float = 16.0
|
||||
|
||||
## The newest release the server knows of, once asked, or empty.
|
||||
var latest_version: String = ""
|
||||
|
||||
@onready var sync_button: Button = %SyncButton
|
||||
@onready var debug_label: Label = $DebugLabel
|
||||
@onready var update_box: HBoxContainer = $UpdateBox
|
||||
@onready var update_button: Button = %UpdateButton
|
||||
@onready var offline_label: Label = $OfflineLabel
|
||||
@onready var qr_button: Button = %QRButton
|
||||
@onready var quiz_tab: Button = %QuizTab
|
||||
@onready var statistics_tab: Button = %StatisticsTab
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
$SearchButton.pressed.connect(search_pressed.emit)
|
||||
# Debug builds use the local server, see Settings
|
||||
debug_label.visible = Settings.is_debug
|
||||
sync_button.pressed.connect(sync_pressed.emit)
|
||||
$ShortcutsButton.pressed.connect(shortcuts_pressed.emit)
|
||||
$AboutButton.pressed.connect(about_pressed.emit)
|
||||
$QRButton.pressed.connect(qr_pressed.emit)
|
||||
%SettingsButton.pressed.connect(settings_pressed.emit)
|
||||
%ShortcutsButton.pressed.connect(shortcuts_pressed.emit)
|
||||
%AboutButton.pressed.connect(about_pressed.emit)
|
||||
qr_button.pressed.connect(qr_pressed.emit)
|
||||
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
|
||||
statistics_tab.pressed.connect(tab_selected.emit.bind(&"statistics"))
|
||||
update_button.pressed.connect(func() -> void: update_pressed.emit(latest_version))
|
||||
# The buttons are as wide as the theme makes them, so the message fits between
|
||||
statistics_tab.item_rect_changed.connect(_fit_middle)
|
||||
sync_button.item_rect_changed.connect(_fit_middle)
|
||||
_fit_middle()
|
||||
update_offline_state()
|
||||
check_for_update()
|
||||
|
||||
|
||||
## Turns the buttons that need the server off or on, after a music folder was picked or
|
||||
## the server is used again.
|
||||
func update_offline_state() -> void:
|
||||
var offline: bool = Settings.is_offline()
|
||||
for button: Button in [sync_button, qr_button, statistics_tab]:
|
||||
button.disabled = offline
|
||||
button.tooltip_text = OFFLINE_TOOLTIP if offline else ""
|
||||
offline_label.visible = offline
|
||||
if offline:
|
||||
offline_label.text = "Offline: " + Settings.music_folder.display_name()
|
||||
offline_label.tooltip_text = Settings.music_folder.path
|
||||
update_box.visible = false
|
||||
|
||||
|
||||
## Asks the server for the newest MusicPlayer release and tells the user when it is newer
|
||||
## than this one. Not asked while a music folder is used.
|
||||
func check_for_update() -> void:
|
||||
if Settings.is_offline():
|
||||
update_box.visible = false
|
||||
return
|
||||
MusicServerClient.fetch_latest_client_version(show_latest_version)
|
||||
|
||||
|
||||
## Shows that [param latest] is available when it is newer than this version, and hides
|
||||
## the message otherwise.
|
||||
func show_latest_version(latest: Variant) -> void:
|
||||
var newer: bool = latest is String && is_newer_version(latest, Settings.version) \
|
||||
&& !Settings.is_offline()
|
||||
update_box.visible = newer
|
||||
if newer:
|
||||
latest_version = latest
|
||||
update_button.text = "Version %s is available" % latest
|
||||
update_button.tooltip_text = "You have %s. Click to update." % Settings.version
|
||||
|
||||
|
||||
# Fits "Version X is available" and the offline label between the tabs and the buttons
|
||||
func _fit_middle() -> void:
|
||||
var left: float = statistics_tab.position.x + statistics_tab.size.x + MIDDLE_GAP
|
||||
var right: float = sync_button.global_position.x - global_position.x - MIDDLE_GAP
|
||||
for middle: Control in [update_box, offline_label]:
|
||||
middle.position.x = left
|
||||
middle.size.x = maxf(right - left, 0.0)
|
||||
|
||||
|
||||
## Whether version [param latest] is newer than [param current], comparing numbers such as
|
||||
## [code]1.10.0[/code] part by part. A leading "v" is ignored, and missing parts count as 0.
|
||||
static func is_newer_version(latest: String, current: String) -> bool:
|
||||
var latest_parts: PackedStringArray = latest.strip_edges().trim_prefix("v").split(".")
|
||||
var current_parts: PackedStringArray = current.strip_edges().trim_prefix("v").split(".")
|
||||
for i: int in maxi(latest_parts.size(), current_parts.size()):
|
||||
var a: int = latest_parts[i].to_int() if i < latest_parts.size() else 0
|
||||
var b: int = current_parts[i].to_int() if i < current_parts.size() else 0
|
||||
if a != b:
|
||||
return a > b
|
||||
return false
|
||||
|
||||
|
||||
## Shows [param tab] as the chosen tab, without emitting [signal tab_selected].
|
||||
|
||||
+54
-32
@@ -1,9 +1,10 @@
|
||||
[gd_scene load_steps=10 format=3 uid="uid://ds15cgsf8vpvc"]
|
||||
[gd_scene format=3 uid="uid://ds15cgsf8vpvc"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://d1v0rquma6nt8" path="res://player/music_player.gd" id="1_t24ra"]
|
||||
[ext_resource type="AudioStream" uid="uid://n2g8jddr85h2" path="res://sounds/01. Opening.mp3" id="2_xti80"]
|
||||
[ext_resource type="Texture2D" uid="uid://comxqfiykp54f" path="res://icons/play_icon_light.svg" id="3_6g308"]
|
||||
[ext_resource type="Texture2D" uid="uid://ccb6rvbldlgdg" path="res://icons/reload_light_icon.svg" id="4_jleuo"]
|
||||
[ext_resource type="Texture2D" path="res://icons/play_button_icon.svg" id="3_6g308"]
|
||||
[ext_resource type="Texture2D" path="res://icons/restart_light_icon.svg" id="4_jleuo"]
|
||||
[ext_resource type="Texture2D" path="res://icons/volume_light_icon.svg" id="6_volume"]
|
||||
[ext_resource type="PackedScene" uid="uid://w400rnew7453" path="res://player/VolumeSlider.tscn" id="5_iifuj"]
|
||||
|
||||
[sub_resource type="InputEventKey" id="InputEventKey_r4qo3"]
|
||||
@@ -29,7 +30,7 @@ events = [SubResource("InputEventKey_2nvce")]
|
||||
[node name="MusicPlayer" type="Control"]
|
||||
layout_mode = 3
|
||||
offset_right = 1792.0
|
||||
offset_bottom = 71.0
|
||||
offset_bottom = 42.0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_t24ra")
|
||||
|
||||
@@ -46,62 +47,83 @@ stream = ExtResource("2_xti80")
|
||||
bus = &"music"
|
||||
|
||||
[node name="PlayerBar" type="PanelContainer" parent="."]
|
||||
layout_mode = 0
|
||||
offset_left = 1.0
|
||||
offset_right = 1792.0
|
||||
offset_bottom = 32.0
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"PlayerBar"
|
||||
|
||||
[node name="MusicPlayerContainer" type="HBoxContainer" parent="PlayerBar"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="PlayTextureButton" type="TextureButton" parent="PlayerBar/MusicPlayerContainer"]
|
||||
[node name="PlayButton" type="Button" parent="PlayerBar/MusicPlayerContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(32, 32)
|
||||
custom_minimum_size = Vector2(34, 34)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
tooltip_text = "Play or pause (Alt+X)"
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"PlayerButtonPrimary"
|
||||
action_mode = 0
|
||||
shortcut = SubResource("Shortcut_ipcfh")
|
||||
texture_normal = ExtResource("3_6g308")
|
||||
ignore_texture_size = true
|
||||
stretch_mode = 0
|
||||
shortcut_feedback = false
|
||||
shortcut_in_tooltip = false
|
||||
icon = ExtResource("3_6g308")
|
||||
icon_alignment = 1
|
||||
expand_icon = true
|
||||
|
||||
[node name="RestartTextureButton" type="TextureButton" parent="PlayerBar/MusicPlayerContainer"]
|
||||
[node name="RestartButton" type="Button" parent="PlayerBar/MusicPlayerContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(32, 32)
|
||||
custom_minimum_size = Vector2(34, 34)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
tooltip_text = "Restart the song (Alt+Z)"
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"PlayerButton"
|
||||
action_mode = 0
|
||||
shortcut = SubResource("Shortcut_i7swq")
|
||||
texture_normal = ExtResource("4_jleuo")
|
||||
ignore_texture_size = true
|
||||
stretch_mode = 0
|
||||
flip_h = true
|
||||
shortcut_feedback = false
|
||||
shortcut_in_tooltip = false
|
||||
icon = ExtResource("4_jleuo")
|
||||
icon_alignment = 1
|
||||
expand_icon = true
|
||||
|
||||
[node name="MusicPlayerSlider" type="HSlider" parent="PlayerBar/MusicPlayerContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"ProgressSlider"
|
||||
scrollable = false
|
||||
|
||||
[node name="MusicTimeLabel" type="Label" parent="PlayerBar/MusicPlayerContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(120, 0)
|
||||
layout_mode = 2
|
||||
text = "0:00 / 0:00"
|
||||
theme_type_variation = &"PlayerTime"
|
||||
text = "00:00 / 00:00"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="VolumeIcon" type="Button" parent="PlayerBar/MusicPlayerContainer"]
|
||||
custom_minimum_size = Vector2(22, 22)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
mouse_filter = 2
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"PlayerIcon"
|
||||
icon = ExtResource("6_volume")
|
||||
icon_alignment = 1
|
||||
expand_icon = true
|
||||
|
||||
[node name="VolumeSlider" parent="PlayerBar/MusicPlayerContainer" instance=ExtResource("5_iifuj")]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 0)
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
|
||||
[node name="RepeatSongCheckButton" type="CheckButton" parent="."]
|
||||
layout_mode = 0
|
||||
offset_top = 40.0
|
||||
offset_right = 144.0
|
||||
offset_bottom = 71.0
|
||||
focus_mode = 0
|
||||
button_pressed = true
|
||||
action_mode = 0
|
||||
text = "Auto repeat"
|
||||
tooltip_text = "Volume"
|
||||
theme_type_variation = &"VolumeBar"
|
||||
+139
-73
@@ -1,53 +1,118 @@
|
||||
class_name MusicPlayer
|
||||
extends Control
|
||||
## The bar that plays the songs, with play, restart, progress and volume, and the
|
||||
## auto repeat switch under it.
|
||||
## The bar that plays the songs, with play, restart, progress and volume. Whether a song
|
||||
## repeats is a quiz setting ([code]Settings.auto_repeat_song[/code]).
|
||||
##
|
||||
## The buttons grow a little under the mouse and the play button pops when it switches
|
||||
## between play and pause, see [ButtonBounce].
|
||||
##
|
||||
## With Short songs on ([member Settings.short_songs]) only a part of each quiz song plays:
|
||||
## [member Settings.short_song_seconds] from a random point between
|
||||
## [constant SHORT_START_MIN] and [constant SHORT_START_MAX] of the song. The progress bar,
|
||||
## the time and seeking all stay within that part, and its end counts as the song ending.
|
||||
|
||||
signal update_song_list
|
||||
signal play_next_song
|
||||
|
||||
var play_icon: Texture = preload("res://icons/play_icon_light.svg")
|
||||
var pause_icon: Texture = preload("res://icons/pause_icon_light.svg")
|
||||
var songs: Array = []
|
||||
var play_icon: Texture = preload("res://icons/play_button_icon.svg")
|
||||
var pause_icon: Texture = preload("res://icons/pause_button_icon.svg")
|
||||
var is_changing: bool = false
|
||||
var playback_position: float
|
||||
var stream: AudioStream
|
||||
var song_finished: bool = false
|
||||
|
||||
@onready var play_button: TextureButton = %PlayTextureButton
|
||||
@onready var restart_button: TextureButton = %RestartTextureButton
|
||||
## Where in the song the part played with Short songs may start, as a share of its length.
|
||||
## Many songs have a quiet intro, so the start is never right at the beginning.
|
||||
const SHORT_START_MIN: float = 0.1
|
||||
const SHORT_START_MAX: float = 0.6
|
||||
|
||||
# The part of the song that plays: its start in seconds, and its length (0: to the end)
|
||||
var _clip_start: float = 0.0
|
||||
var _clip_length: float = 0.0
|
||||
|
||||
@onready var play_button: Button = %PlayButton
|
||||
@onready var restart_button: Button = %RestartButton
|
||||
@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer
|
||||
@onready var sound_player: AudioStreamPlayer = $AudioSoundPlayer
|
||||
@onready var sound_effect_player: AudioStreamPlayer = $AudioSoundEffectPlayer
|
||||
@onready var progress_slider: HSlider = %MusicPlayerSlider
|
||||
@onready var volume_slider: VolumeSlider = %VolumeSlider
|
||||
@onready var music_time_label: Label = %MusicTimeLabel
|
||||
@onready var auto_repeat_button: CheckButton = $RepeatSongCheckButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
play_button.pressed.connect(play_or_pause)
|
||||
restart_button.pressed.connect(restart)
|
||||
ButtonBounce.add_to(play_button)
|
||||
ButtonBounce.add_to(restart_button)
|
||||
progress_slider.drag_started.connect(_on_drag_started)
|
||||
progress_slider.drag_ended.connect(_on_drag_ended)
|
||||
|
||||
audio_player.finished.connect(_on_finished)
|
||||
auto_repeat_button.pressed.connect(_on_auto_repeat_pressed)
|
||||
|
||||
|
||||
func _on_auto_repeat_pressed() -> void:
|
||||
Settings.auto_repeat_song = !Settings.auto_repeat_song
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused:
|
||||
if !Settings.hide_beginning \
|
||||
|| audio_player.get_playback_position() > Settings.hide_for_seconds:
|
||||
progress_slider.value = audio_player.get_playback_position()
|
||||
if stream != null:
|
||||
music_time_label.text = format_text(progress_slider.value, stream.get_length())
|
||||
else:
|
||||
music_time_label.text = format_text(progress_slider.value, 0.0)
|
||||
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused \
|
||||
&& audio_player.playing:
|
||||
var position: float = position_in_part()
|
||||
if _clip_length > 0.0 && position >= _clip_length:
|
||||
# The short part has played, which counts as the song ending
|
||||
audio_player.stop()
|
||||
progress_slider.value = _clip_length
|
||||
_on_finished()
|
||||
return
|
||||
if !Settings.hide_beginning || position > Settings.hide_for_seconds:
|
||||
progress_slider.value = position
|
||||
music_time_label.text = format_text(progress_slider.value, part_length())
|
||||
|
||||
|
||||
## How long the part of the song that plays is: the short part, or the whole song.
|
||||
func part_length() -> float:
|
||||
if _clip_length > 0.0:
|
||||
return _clip_length
|
||||
return stream.get_length() if stream != null else 0.0
|
||||
|
||||
|
||||
## How far into the part that plays the song is, in seconds.
|
||||
func position_in_part() -> float:
|
||||
return audio_player.get_playback_position() - _clip_start
|
||||
|
||||
|
||||
## Plays only part of [param song_object]'s song when Short songs is on and the song is
|
||||
## longer than the part. The part's start is picked once per song.
|
||||
func choose_part(song_object: SongObject, song: AudioStream) -> void:
|
||||
_clip_start = 0.0
|
||||
_clip_length = 0.0
|
||||
var length: float = song.get_length()
|
||||
if !Settings.short_songs || song_object == null || length <= Settings.short_song_seconds:
|
||||
return
|
||||
if song_object.short_start < 0.0:
|
||||
var latest: float = minf(length * SHORT_START_MAX, length - Settings.short_song_seconds)
|
||||
var earliest: float = minf(length * SHORT_START_MIN, latest)
|
||||
song_object.short_start = randf_range(earliest, latest)
|
||||
_clip_start = minf(song_object.short_start, length - Settings.short_song_seconds)
|
||||
_clip_length = Settings.short_song_seconds
|
||||
|
||||
|
||||
## Whether the play button shows pause, because a song is playing.
|
||||
func shows_playing() -> bool:
|
||||
return play_button.icon == pause_icon
|
||||
|
||||
|
||||
# Shows pause on the play button while a song plays, and play otherwise
|
||||
func _show_playing(playing: bool) -> void:
|
||||
var icon: Texture = pause_icon if playing else play_icon
|
||||
if play_button.icon != icon:
|
||||
play_button.icon = icon
|
||||
ButtonBounce.pop(play_button)
|
||||
|
||||
|
||||
# The progress bar and time show the length of the part that plays
|
||||
func _show_length() -> void:
|
||||
var length: float = part_length()
|
||||
progress_slider.max_value = round(length)
|
||||
progress_slider.tick_count = 0 if Settings.hide_ticks else round(length / 60)
|
||||
music_time_label.text = format_text(progress_slider.value, length)
|
||||
|
||||
|
||||
func format_time(time: float) -> String:
|
||||
@@ -71,16 +136,12 @@ func play_or_pause() -> void:
|
||||
if song_finished:
|
||||
restart()
|
||||
elif audio_player.stream_paused:
|
||||
play_button.texture_normal = pause_icon
|
||||
_show_playing(true)
|
||||
audio_player.stream_paused = false
|
||||
song_finished = false
|
||||
audio_player.seek(playback_position)
|
||||
GameLog.debug("continue")
|
||||
progress_slider.max_value = round(stream.get_length())
|
||||
if Settings.hide_ticks:
|
||||
progress_slider.tick_count = 0
|
||||
else:
|
||||
progress_slider.tick_count = round(stream.get_length() / 60)
|
||||
_show_length()
|
||||
else:
|
||||
pause()
|
||||
|
||||
@@ -88,22 +149,22 @@ func play_or_pause() -> void:
|
||||
func pause() -> void:
|
||||
audio_player.stream_paused = true
|
||||
playback_position = audio_player.get_playback_position()
|
||||
play_button.texture_normal = play_icon
|
||||
_show_playing(false)
|
||||
|
||||
|
||||
## Plays the song, or its short part, from the beginning.
|
||||
func restart() -> void:
|
||||
audio_player.stop()
|
||||
audio_player.stream_paused = false
|
||||
song_finished = false
|
||||
progress_slider.value = 0
|
||||
playback_position = audio_player.get_playback_position()
|
||||
audio_player.seek(playback_position)
|
||||
play_button.texture_normal = pause_icon
|
||||
audio_player.play()
|
||||
playback_position = _clip_start
|
||||
_show_playing(true)
|
||||
audio_player.play(_clip_start)
|
||||
|
||||
|
||||
func _on_finished() -> void:
|
||||
play_button.texture_normal = play_icon
|
||||
_show_playing(false)
|
||||
song_finished = true
|
||||
if !Settings.stop_after_current:
|
||||
play_next_song.emit()
|
||||
@@ -116,16 +177,18 @@ func _on_drag_started() -> void:
|
||||
|
||||
|
||||
func _on_drag_ended(_changed: bool) -> void:
|
||||
audio_player.seek(progress_slider.value)
|
||||
playback_position = progress_slider.value
|
||||
audio_player.seek(_clip_start + progress_slider.value)
|
||||
playback_position = _clip_start + progress_slider.value
|
||||
is_changing = false
|
||||
|
||||
|
||||
## Jumps [param new_position] seconds forward, or back when negative, staying within the
|
||||
## part that plays.
|
||||
func seek(new_position: float) -> void:
|
||||
progress_slider.value += new_position
|
||||
is_changing = true
|
||||
audio_player.seek(progress_slider.value)
|
||||
playback_position = progress_slider.value
|
||||
audio_player.seek(_clip_start + progress_slider.value)
|
||||
playback_position = _clip_start + progress_slider.value
|
||||
is_changing = false
|
||||
|
||||
|
||||
@@ -157,44 +220,59 @@ func play_sound(sound_name: AudioStream) -> void:
|
||||
audio_player.stop()
|
||||
sound_player.play()
|
||||
song_finished = true
|
||||
play_button.texture_normal = play_icon
|
||||
_show_playing(false)
|
||||
progress_slider.value = 0
|
||||
|
||||
|
||||
func play_song(song: AudioStream) -> void:
|
||||
# Sounds such as the winning song always play whole
|
||||
_clip_start = 0.0
|
||||
_clip_length = 0.0
|
||||
audio_player.stream = song
|
||||
audio_player.play()
|
||||
sound_player.stop()
|
||||
song_finished = false
|
||||
play_button.texture_normal = pause_icon
|
||||
_show_playing(true)
|
||||
stream = audio_player.stream
|
||||
progress_slider.value = 0
|
||||
progress_slider.max_value = round(stream.get_length())
|
||||
if Settings.hide_ticks:
|
||||
progress_slider.tick_count = 0
|
||||
else:
|
||||
progress_slider.tick_count = round(stream.get_length() / 60)
|
||||
music_time_label.text = format_text(progress_slider.value, stream.get_length())
|
||||
_show_length()
|
||||
|
||||
|
||||
## Puts song [param song_object_no] in the player without playing it, e.g. the song that
|
||||
## was playing when the game was closed. Play starts it from the beginning.
|
||||
func load_song_object(song_object_no: int) -> void:
|
||||
var song: AudioStream = Playlist.get_song(song_object_no)
|
||||
if song == null:
|
||||
return
|
||||
audio_player.stream = song
|
||||
stream = song
|
||||
choose_part(Playlist.song_object_array[song_object_no], song)
|
||||
song_finished = true
|
||||
_show_playing(false)
|
||||
progress_slider.value = 0
|
||||
_show_length()
|
||||
|
||||
|
||||
func play_song_object(song_object_no: int) -> void:
|
||||
GameLog.debug("play_song_object")
|
||||
var song: AudioStream = Playlist.get_song(song_object_no)
|
||||
if song == null:
|
||||
GameLog.warning("Song ", song_object_no, " has no file to play")
|
||||
return
|
||||
if audio_player.is_playing():
|
||||
audio_player.stop()
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
audio_player.stream = Playlist.get_song(song_object_no)
|
||||
audio_player.stream = song
|
||||
choose_part(Playlist.song_object_array[song_object_no], song)
|
||||
sound_player.stop()
|
||||
audio_player.play()
|
||||
audio_player.play(_clip_start)
|
||||
song_finished = false
|
||||
play_button.texture_normal = pause_icon
|
||||
_show_playing(true)
|
||||
# A new song makes the play button pop, also when it was already playing
|
||||
ButtonBounce.pop(play_button)
|
||||
stream = audio_player.stream
|
||||
progress_slider.value = 0
|
||||
progress_slider.max_value = round(stream.get_length())
|
||||
if Settings.hide_ticks:
|
||||
progress_slider.tick_count = 0
|
||||
else:
|
||||
progress_slider.tick_count = round(stream.get_length() / 60)
|
||||
music_time_label.text = format_text(progress_slider.value, stream.get_length())
|
||||
_show_length()
|
||||
Playlist.set_currently_playing_song(song_object_no)
|
||||
Playlist.unset_is_playing()
|
||||
if !Settings.hide_next_track:
|
||||
@@ -205,31 +283,19 @@ func play_song_object(song_object_no: int) -> void:
|
||||
update_song_list.emit()
|
||||
|
||||
|
||||
## Plays a song to test the sound: one from the server, or a song that comes with the
|
||||
## game when playing from a music folder.
|
||||
func get_sound_test_song() -> void:
|
||||
if Settings.is_offline():
|
||||
sound_test_local()
|
||||
return
|
||||
var test_sound: Callable = func test_sound(body: PackedByteArray) -> void:
|
||||
var sound: AudioStream = AudioStreamMP3.new()
|
||||
sound.data = body
|
||||
play_song(sound)
|
||||
MusicServerClient.fetch_sound_test_song(test_sound)
|
||||
|
||||
##### LOCAL
|
||||
var local_path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3'
|
||||
|
||||
|
||||
func load_mp3(_path: String) -> AudioStream:
|
||||
var file: FileAccess = FileAccess.open(_path, FileAccess.READ)
|
||||
var sound: AudioStream = AudioStreamMP3.new()
|
||||
sound.data = file.get_buffer(file.get_length())
|
||||
return sound
|
||||
|
||||
|
||||
func play_local_song() -> void:
|
||||
if songs:
|
||||
local_path = songs[0]
|
||||
GameLog.debug(local_path)
|
||||
GameLog.debug(FileAccess.file_exists(local_path))
|
||||
play_sound(load_mp3(local_path))
|
||||
|
||||
|
||||
# The sound test without a server: a song that comes with the game
|
||||
func sound_test_local() -> void:
|
||||
play_sound(preload("res://sounds/01. Opening.mp3"))
|
||||
play_song(preload("res://sounds/01. Opening.mp3"))
|
||||
+64
-22
@@ -15,45 +15,87 @@ panel = NodePath("AboutPanel")
|
||||
[node name="AboutPanel" type="Panel" parent="." unique_id=1907299275]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_right = 600.0
|
||||
offset_right = 1200.0
|
||||
offset_bottom = 640.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="AboutPanel" unique_id=200295721]
|
||||
layout_mode = 0
|
||||
offset_right = 600.0
|
||||
offset_right = 1200.0
|
||||
offset_bottom = 50.0
|
||||
text = "About"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="AboutPanel" unique_id=417677913]
|
||||
[node name="DescriptionLabel" type="Label" parent="AboutPanel" unique_id=543094263]
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 50.0
|
||||
offset_right = 580.0
|
||||
offset_bottom = 630.0
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="ContentVBox" type="VBoxContainer" parent="AboutPanel/ScrollContainer" unique_id=1746337912]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VersionLabel" type="Label" parent="AboutPanel/ScrollContainer/ContentVBox" unique_id=352884126]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="DescriptionLabel" type="Label" parent="AboutPanel/ScrollContainer/ContentVBox" unique_id=543094263]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
offset_right = 1180.0
|
||||
offset_bottom = 80.0
|
||||
text = "Try your video game music knowledge with this VGM randomizer, invite your friends and see who is the best."
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="WhatsNewLabel" type="Label" parent="AboutPanel/ScrollContainer/ContentVBox" unique_id=1363040291]
|
||||
[node name="Columns" type="HBoxContainer" parent="AboutPanel" unique_id=200295722]
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 96.0
|
||||
offset_right = 1180.0
|
||||
offset_bottom = 630.0
|
||||
theme_override_constants/separation = 40
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="AboutPanel/Columns" unique_id=417677913]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="ContentVBox" type="VBoxContainer" parent="AboutPanel/Columns/ScrollContainer" unique_id=1746337912]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="PlayerHeader" type="Label" parent="AboutPanel/Columns/ScrollContainer/ContentVBox" unique_id=1746337913]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightLarge"
|
||||
text = "Music Player"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="VersionLabel" type="Label" parent="AboutPanel/Columns/ScrollContainer/ContentVBox" unique_id=352884126]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="WhatsNewLabel" type="Label" parent="AboutPanel/Columns/ScrollContainer/ContentVBox" unique_id=1363040291]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="ServerScrollContainer" type="ScrollContainer" parent="AboutPanel/Columns" unique_id=417677914]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="ServerContentVBox" type="VBoxContainer" parent="AboutPanel/Columns/ServerScrollContainer" unique_id=1746337914]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="ServerHeader" type="Label" parent="AboutPanel/Columns/ServerScrollContainer/ServerContentVBox" unique_id=1746337915]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightLarge"
|
||||
text = "Music Server"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ServerVersionLabel" type="Label" parent="AboutPanel/Columns/ServerScrollContainer/ServerContentVBox" unique_id=352884127]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ServerHistoryLabel" type="Label" parent="AboutPanel/Columns/ServerScrollContainer/ServerContentVBox" unique_id=1363040292]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
+21
-22
@@ -3,40 +3,39 @@
|
||||
[ext_resource type="Script" uid="uid://i35b3scbpwpu" path="res://popups/character_card.gd" id="1_pjt0s"]
|
||||
[ext_resource type="Texture2D" uid="uid://cv3hdd6bsruau" path="res://icon.svg" id="2_pjt0s"]
|
||||
|
||||
[node name="CharacterCard" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
[node name="CharacterCard" type="Panel"]
|
||||
custom_minimum_size = Vector2(240, 150)
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_type_variation = &"CharacterCard"
|
||||
script = ExtResource("1_pjt0s")
|
||||
|
||||
[node name="Panel" type="Panel" parent="."]
|
||||
layout_mode = 0
|
||||
offset_right = 240.0
|
||||
offset_bottom = 145.0
|
||||
theme_type_variation = &"CharacterCard"
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 10.0
|
||||
offset_top = 10.0
|
||||
offset_right = -10.0
|
||||
offset_bottom = -6.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="Panel"]
|
||||
layout_mode = 0
|
||||
offset_right = 240.0
|
||||
offset_bottom = 145.0
|
||||
theme_override_constants/margin_left = 15
|
||||
theme_override_constants/margin_top = 10
|
||||
theme_override_constants/margin_right = 15
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="Panel/MarginContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="CharacterTextureRect" type="TextureRect" parent="Panel/MarginContainer/VBoxContainer"]
|
||||
[node name="CharacterTextureRect" type="TextureRect" parent="VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(200, 100)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
mouse_filter = 2
|
||||
texture = ExtResource("2_pjt0s")
|
||||
expand_mode = 3
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="CharacterName" type="Label" parent="Panel/MarginContainer/VBoxContainer"]
|
||||
[node name="CharacterName" type="Label" parent="VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Aloy"
|
||||
horizontal_alignment = 1
|
||||
text_overrun_behavior = 3
|
||||
@@ -11,45 +11,42 @@ panel = NodePath("CharacterPanel")
|
||||
[node name="CharacterPanel" type="Panel" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_right = 1290.0
|
||||
offset_bottom = 665.0
|
||||
offset_right = 1580.0
|
||||
offset_bottom = 720.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CharacterPanel"]
|
||||
layout_mode = 0
|
||||
offset_right = 1290.0
|
||||
offset_bottom = 659.0
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CharacterPanel/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="CharacterPanel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 0
|
||||
layout_mode = 0
|
||||
offset_right = 1580.0
|
||||
offset_bottom = 56.0
|
||||
theme_type_variation = &"Title"
|
||||
text = "Character Select"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="CharacterPanel/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 20
|
||||
theme_override_constants/margin_top = 10
|
||||
theme_override_constants/margin_right = 20
|
||||
theme_override_constants/margin_bottom = 10
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="CharacterPanel/VBoxContainer/MarginContainer"]
|
||||
custom_minimum_size = Vector2(0, 600)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CharacterGridContainer" type="GridContainer" parent="CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="CharacterPanel"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(1150, 600)
|
||||
layout_mode = 0
|
||||
offset_left = 24.0
|
||||
offset_top = 60.0
|
||||
offset_right = 1564.0
|
||||
offset_bottom = 704.0
|
||||
horizontal_scroll_mode = 0
|
||||
vertical_scroll_mode = 2
|
||||
|
||||
[node name="ScrollbarGap" type="MarginContainer" parent="CharacterPanel/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/h_separation = 10
|
||||
theme_override_constants/v_separation = 10
|
||||
columns = 5
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/margin_right = 24
|
||||
|
||||
[node name="CharacterGridContainer" type="GridContainer" parent="CharacterPanel/ScrollContainer/ScrollbarGap"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
theme_override_constants/h_separation = 12
|
||||
theme_override_constants/v_separation = 12
|
||||
columns = 6
|
||||
|
||||
[node name="CloseTimer" type="Timer" parent="CharacterPanel"]
|
||||
unique_name_in_owner = true
|
||||
@@ -31,7 +31,19 @@ theme_type_variation = &"Title"
|
||||
text = "Log"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="EmptyLabel" type="Label" parent="LogPanel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 80.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 110.0
|
||||
theme_type_variation = &"TextLarge"
|
||||
text = "Nothing has happened yet"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LogScrollContainer" type="ScrollContainer" parent="LogPanel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 56.0
|
||||
|
||||
+15
-5
@@ -17,16 +17,26 @@ panel = NodePath("QRPanel")
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_right = 450.0
|
||||
offset_bottom = 520.0
|
||||
offset_bottom = 540.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="QRPanel" unique_id=1021365844]
|
||||
layout_mode = 0
|
||||
offset_top = 8.0
|
||||
offset_right = 450.0
|
||||
offset_bottom = 56.0
|
||||
theme_type_variation = &"Title"
|
||||
text = "Scan to search on your phone"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="QRCodeRect" type="TextureRect" parent="QRPanel" unique_id=1054378438]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 20.0
|
||||
offset_top = 60.0
|
||||
offset_right = 430.0
|
||||
offset_bottom = 430.0
|
||||
offset_bottom = 470.0
|
||||
script = ExtResource("2_jpsxt")
|
||||
auto_update = true
|
||||
mode = 4
|
||||
@@ -47,9 +57,9 @@ metadata/_custom_type_script = "uid://dynulhnyq5hpq"
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 450.0
|
||||
offset_top = 482.0
|
||||
offset_right = 430.0
|
||||
offset_bottom = 490.0
|
||||
offset_bottom = 522.0
|
||||
text = "URL"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
+53
-27
@@ -16,37 +16,63 @@ panel = NodePath("SearchPanel")
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_right = 1152.0
|
||||
offset_bottom = 640.0
|
||||
offset_bottom = 880.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="ClearButton" type="Button" parent="." unique_id=1549439398]
|
||||
[node name="TopRow" type="HBoxContainer" parent="SearchPanel" unique_id=1262945995]
|
||||
layout_mode = 0
|
||||
offset_left = 936.0
|
||||
offset_top = 16.0
|
||||
offset_right = 1027.0
|
||||
offset_bottom = 56.0
|
||||
scale = Vector2(2, 2)
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Clear"
|
||||
offset_left = 24.0
|
||||
offset_top = 24.0
|
||||
offset_right = 1128.0
|
||||
offset_bottom = 80.0
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="Searchbar" type="TextEdit" parent="." unique_id=2112669372]
|
||||
layout_mode = 0
|
||||
offset_left = 32.0
|
||||
offset_top = 16.0
|
||||
offset_right = 468.0
|
||||
offset_bottom = 56.0
|
||||
scale = Vector2(2, 2)
|
||||
placeholder_text = "Search"
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=213733486]
|
||||
layout_mode = 0
|
||||
offset_top = 128.0
|
||||
offset_right = 576.0
|
||||
offset_bottom = 384.0
|
||||
scale = Vector2(2, 2)
|
||||
|
||||
[node name="SearchResults" type="VBoxContainer" parent="ScrollContainer" unique_id=1567403144]
|
||||
[node name="SearchField" type="LineEdit" parent="SearchPanel/TopRow" unique_id=2112669372]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"SearchField"
|
||||
placeholder_text = "Search"
|
||||
caret_blink = true
|
||||
|
||||
[node name="ClearButton" type="Button" parent="SearchPanel/TopRow" unique_id=1549439398]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(140, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"LargeButton"
|
||||
action_mode = 0
|
||||
text = "Clear"
|
||||
|
||||
[node name="CountLabel" type="Label" parent="SearchPanel" unique_id=1262945996]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 24.0
|
||||
offset_top = 90.0
|
||||
offset_right = 1128.0
|
||||
offset_bottom = 116.0
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="SearchPanel" unique_id=213733486]
|
||||
layout_mode = 0
|
||||
offset_left = 24.0
|
||||
offset_top = 124.0
|
||||
offset_right = 1128.0
|
||||
offset_bottom = 856.0
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="SearchResults" type="VBoxContainer" parent="SearchPanel/ScrollContainer" unique_id=1567403144]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="EmptyLabel" type="Label" parent="SearchPanel" unique_id=1262945997]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 0
|
||||
offset_left = 24.0
|
||||
offset_top = 160.0
|
||||
offset_right = 1128.0
|
||||
offset_bottom = 200.0
|
||||
theme_type_variation = &"Title"
|
||||
text = "No soundtracks found"
|
||||
horizontal_alignment = 1
|
||||
+235
-195
@@ -15,241 +15,281 @@ panel = NodePath("SettingsPanel")
|
||||
[node name="SettingsPanel" type="Panel" parent="." unique_id=45537159]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_right = 500.0
|
||||
offset_bottom = 640.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 270.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="SettingsPanel" unique_id=1325795358]
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="SettingsPanel" unique_id=1796635163]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 8.0
|
||||
offset_right = 480.0
|
||||
offset_bottom = 632.0
|
||||
horizontal_scroll_mode = 0
|
||||
offset_top = 12.0
|
||||
offset_right = 500.0
|
||||
offset_bottom = 258.0
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="SettingsPanel/ScrollContainer" unique_id=1796635163]
|
||||
clip_contents = true
|
||||
[node name="Label" type="Label" parent="SettingsPanel/VBoxContainer" unique_id=1111531402]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Label" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1111531402]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 0
|
||||
theme_type_variation = &"Title"
|
||||
text = "Settings"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="StopAfterCurrentCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=508305575]
|
||||
[node name="ServerRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer" unique_id=434350695]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ServerRow" unique_id=434350696]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Server"
|
||||
|
||||
[node name="SelectServerButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/ServerRow" unique_id=434350694]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
button_pressed = true
|
||||
action_mode = 0
|
||||
text = "Stop after current"
|
||||
focus_mode = 0
|
||||
|
||||
[node name="HideNextTrackCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1568244599]
|
||||
[node name="FolderRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/FolderRow"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Music folder"
|
||||
|
||||
[node name="FolderLabel" type="Label" parent="SettingsPanel/VBoxContainer/FolderRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
button_pressed = true
|
||||
action_mode = 0
|
||||
text = "Hide next track"
|
||||
mouse_filter = 1
|
||||
text = "None, using the server"
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1673750542]
|
||||
[node name="ChooseFolderButton" type="Button" parent="SettingsPanel/VBoxContainer/FolderRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
action_mode = 0
|
||||
text = "Turn on played to database"
|
||||
|
||||
[node name="LowPlayedCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1672657048]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
action_mode = 0
|
||||
text = "Use low played mode"
|
||||
|
||||
[node name="HideBeginningCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1370617867]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
action_mode = 0
|
||||
text = "Hide beginning of songs"
|
||||
|
||||
[node name="HideForSecondsHBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1368485120]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HideForSecondsTitle" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=1822369383]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Hide Start Seconds: "
|
||||
|
||||
[node name="HideForSecondsLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=1580108945]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "5.0"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LowerHideForSecondsButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=180643442]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 2.08165e-12)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "-1"
|
||||
text = "Choose…"
|
||||
|
||||
[node name="IncreaseHideForSecondsButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=340681770]
|
||||
[node name="UseServerButton" type="Button" parent="SettingsPanel/VBoxContainer/FolderRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 2.08165e-12)
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "+1"
|
||||
text = "Use server"
|
||||
|
||||
[node name="HideLengthCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=67806372]
|
||||
[node name="FolderErrorLabel" type="Label" parent="SettingsPanel/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "No songs found"
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="PlayOrderRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/PlayOrderRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
action_mode = 0
|
||||
text = "Hide length of songs"
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Play the prepared quiz"
|
||||
|
||||
[node name="HideTicksCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=706322752]
|
||||
[node name="PlayOrderButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/PlayOrderRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
action_mode = 0
|
||||
text = "Hide ticks in playbar"
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=2140610992]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label2" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=1892907487]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Winning Score: "
|
||||
|
||||
[node name="ScoreLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=444542662]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "20"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LowerButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=614806355]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 2.08165e-12)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "-1"
|
||||
|
||||
[node name="IncreaseButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=1837169457]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 2.08165e-12)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "+1"
|
||||
|
||||
[node name="CacheHBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=658210492]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="CacheTitleLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=365012193]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Predownload songs: "
|
||||
|
||||
[node name="CacheLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=1558370172]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "1"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LowerCacheButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=1990385038]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 2.08165e-12)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "-1"
|
||||
|
||||
[node name="IncreaseCacheButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=1806295533]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 2.08165e-12)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "+1"
|
||||
|
||||
[node name="InspirationHBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1686756540]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="InspirationListSpeedTitle" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=1397861506]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Inspiration List Speed: "
|
||||
|
||||
[node name="InspirationListSpeedLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=867387668]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "1"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LowerInspirationSpeedButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=845209534]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 2.08165e-12)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "-1"
|
||||
|
||||
[node name="IncreaseInspirationSpeedButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=1122701153]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 2.08165e-12)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "+1"
|
||||
|
||||
[node name="SelectServerButton" type="OptionButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=434350694]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
selected = 0
|
||||
item_count = 5
|
||||
popup/item_0/text = "https://music.sanplex.xyz"
|
||||
popup/item_0/id = 1
|
||||
popup/item_1/text = "https://tmusic.sanplex.xyz"
|
||||
popup/item_1/id = 2
|
||||
popup/item_2/text = "https://rmusic.sanplex.xyz"
|
||||
popup/item_2/id = 3
|
||||
popup/item_3/text = "https://old-music.sanplex.xyz"
|
||||
popup/item_3/id = 4
|
||||
popup/item_4/text = "http://localhost:8080"
|
||||
popup/item_4/id = 5
|
||||
item_count = 2
|
||||
popup/item_0/text = "In order"
|
||||
popup/item_0/id = 0
|
||||
popup/item_1/text = "At random"
|
||||
popup/item_1/id = 1
|
||||
|
||||
[node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=400304684]
|
||||
[node name="CharactersRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/CharactersRow"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Characters"
|
||||
|
||||
[node name="CharactersLabel" type="Label" parent="SettingsPanel/VBoxContainer/CharactersRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 1
|
||||
text = "From the server"
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="ChooseCharactersButton" type="Button" parent="SettingsPanel/VBoxContainer/CharactersRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Choose…"
|
||||
|
||||
[node name="UseServerCharactersButton" type="Button" parent="SettingsPanel/VBoxContainer/CharactersRow"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Use server"
|
||||
|
||||
[node name="CharactersErrorLabel" type="Label" parent="SettingsPanel/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "No pictures found"
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="ThemeRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ThemeRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Theme"
|
||||
|
||||
[node name="ThemeButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/ThemeRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(180, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
|
||||
[node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/VBoxContainer" unique_id=400304684]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Fullscreen"
|
||||
|
||||
[node name="QuickSyncButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=49497448]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
button_pressed = true
|
||||
action_mode = 0
|
||||
text = "Quick sync"
|
||||
|
||||
[node name="LocalButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=905844805]
|
||||
[node name="ReduceAnimationsButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Reduce animations"
|
||||
|
||||
[node name="CacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer" unique_id=658210492]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="CacheTitleLabel" type="Label" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=365012193]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
disabled = true
|
||||
text = "Local"
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Songs to download ahead"
|
||||
|
||||
[node name="CacheLabel" type="Label" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1558370172]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "1"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LowerCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1990385038]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "-1"
|
||||
|
||||
[node name="IncreaseCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1806295533]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "+1"
|
||||
|
||||
[node name="RandomModeRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/RandomModeRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Random songs"
|
||||
|
||||
[node name="RandomModeButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/RandomModeRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(180, 0)
|
||||
layout_mode = 2
|
||||
tooltip_text = "Normal: a random soundtrack, then one of its songs.
|
||||
Low played: the same, but soundtracks played less than average come up more often.
|
||||
Classic: every song has the same chance, whatever its soundtrack."
|
||||
focus_mode = 0
|
||||
selected = 0
|
||||
item_count = 3
|
||||
popup/item_0/text = "Normal"
|
||||
popup/item_0/id = 0
|
||||
popup/item_1/text = "Low played"
|
||||
popup/item_1/id = 1
|
||||
popup/item_2/text = "Classic"
|
||||
popup/item_2/id = 2
|
||||
|
||||
[node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Count plays in statistics"
|
||||
|
||||
[node name="ConfigFolderRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ConfigFolderRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Settings and cached files"
|
||||
|
||||
[node name="OpenConfigFolderButton" type="Button" parent="SettingsPanel/VBoxContainer/ConfigFolderRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Open folder"
|
||||
|
||||
[node name="ClearCacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ClearCacheRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Downloaded pictures and songs"
|
||||
|
||||
[node name="CacheSizeLabel" type="Label" parent="SettingsPanel/VBoxContainer/ClearCacheRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ClearCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/ClearCacheRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
tooltip_text = "Deletes the downloaded character pictures, covers and songs.
|
||||
They are downloaded again when needed. Songs in the current quiz are kept."
|
||||
focus_mode = 0
|
||||
text = "Clear cache"
|
||||
|
||||
[node name="FolderDialog" type="FileDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Choose a music folder"
|
||||
initial_position = 2
|
||||
size = Vector2i(800, 500)
|
||||
ok_button_text = "Select Current Folder"
|
||||
file_mode = 2
|
||||
access = 2
|
||||
use_native_dialog = true
|
||||
|
||||
[node name="CharacterFolderDialog" type="FileDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Choose a folder of characters"
|
||||
initial_position = 2
|
||||
size = Vector2i(800, 500)
|
||||
ok_button_text = "Select Current Folder"
|
||||
file_mode = 2
|
||||
access = 2
|
||||
use_native_dialog = true
|
||||
+17
-24
@@ -3,7 +3,6 @@
|
||||
[ext_resource type="Script" uid="uid://rolypd0cqd6h" path="res://popups/shortcuts_popup.gd" id="1_12pxi"]
|
||||
|
||||
[node name="ShortcutsPopup" type="Control" node_paths=PackedStringArray("panel")]
|
||||
custom_minimum_size = Vector2(700, 700)
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -15,35 +14,29 @@ panel = NodePath("ShortcutsPanel")
|
||||
|
||||
[node name="ShortcutsPanel" type="Panel" parent="."]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(700, 700)
|
||||
layout_mode = 0
|
||||
offset_right = 40.0
|
||||
offset_bottom = 40.0
|
||||
offset_right = 700.0
|
||||
offset_bottom = 706.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="ShortcutsPanel"]
|
||||
custom_minimum_size = Vector2(700, 700)
|
||||
[node name="TitleLabel" type="Label" parent="ShortcutsPanel"]
|
||||
layout_mode = 0
|
||||
offset_right = 40.0
|
||||
offset_bottom = 40.0
|
||||
offset_right = 700.0
|
||||
offset_bottom = 56.0
|
||||
theme_type_variation = &"Title"
|
||||
text = "Shortcuts"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="ShortcutsPanel"]
|
||||
layout_mode = 0
|
||||
offset_left = 24.0
|
||||
offset_top = 60.0
|
||||
offset_right = 676.0
|
||||
offset_bottom = 690.0
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="shortcutsVBox" type="VBoxContainer" parent="ShortcutsPanel/ScrollContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(700, 700)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="ShortcutsPanel/ScrollContainer/shortcutsVBox"]
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="Label" type="Label" parent="ShortcutsPanel/ScrollContainer/shortcutsVBox/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "testar"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Label2" type="Label" parent="ShortcutsPanel/ScrollContainer/shortcutsVBox/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "testera 2"
|
||||
horizontal_alignment = 1
|
||||
@@ -30,19 +30,87 @@ theme_type_variation = &"Title"
|
||||
horizontal_alignment = 1
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="SummaryLabel" type="Label" parent="StatsPanel" unique_id=1213000004]
|
||||
[node name="CoverFrame" type="Panel" parent="StatsPanel"]
|
||||
unique_name_in_owner = true
|
||||
clip_children = 2
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 64.0
|
||||
offset_right = 160.0
|
||||
offset_bottom = 274.0
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"SoundtrackCover"
|
||||
|
||||
[node name="CoverPlaceholder" type="Label" parent="StatsPanel/CoverFrame"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_type_variation = &"TitleMedium"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CoverPicture" type="TextureRect" parent="StatsPanel/CoverFrame"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
expand_mode = 1
|
||||
stretch_mode = 6
|
||||
|
||||
[node name="Info" type="VBoxContainer" parent="StatsPanel"]
|
||||
layout_mode = 0
|
||||
offset_left = 180.0
|
||||
offset_top = 64.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 264.0
|
||||
|
||||
[node name="SummaryLabel" type="Label" parent="StatsPanel/Info" unique_id=1213000004]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="NamesTitle" type="Label" parent="StatsPanel/Info"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Also known as"
|
||||
|
||||
[node name="NamesLabel" type="Label" parent="StatsPanel/Info"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"TextMedium"
|
||||
autowrap_mode = 3
|
||||
text_overrun_behavior = 3
|
||||
max_lines_visible = 3
|
||||
|
||||
[node name="InfoButtons" type="HBoxContainer" parent="StatsPanel/Info"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="EditNamesButton" type="Button" parent="StatsPanel/Info/InfoButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Edit names"
|
||||
|
||||
[node name="ChangeCoverButton" type="Button" parent="StatsPanel/Info/InfoButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Change cover"
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 54.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 80.0
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 92.0
|
||||
offset_top = 280.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 784.0
|
||||
horizontal_scroll_mode = 0
|
||||
@@ -51,3 +119,181 @@ horizontal_scroll_mode = 0
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="NamesEditor" type="VBoxContainer" parent="StatsPanel"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 280.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 784.0
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="EditorTitle" type="Label" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Title"
|
||||
text = "Alternative names"
|
||||
|
||||
[node name="EditorHelp" type="Label" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
text = "Other names the soundtrack goes by, e.g. short names or the name in another language. Search finds it by any of them."
|
||||
|
||||
[node name="EditorScroll" type="ScrollContainer" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="EditorNames" type="VBoxContainer" parent="StatsPanel/NamesEditor/EditorScroll"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AddRow" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="NewNameField" type="LineEdit" parent="StatsPanel/NamesEditor/AddRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Another name"
|
||||
|
||||
[node name="AddNameButton" type="Button" parent="StatsPanel/NamesEditor/AddRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Add"
|
||||
|
||||
[node name="EditorStatus" type="Label" parent="StatsPanel/NamesEditor"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="EditorButtons" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 2
|
||||
|
||||
[node name="CancelNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Cancel"
|
||||
|
||||
[node name="SaveNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Save"
|
||||
|
||||
[node name="CoverEditor" type="VBoxContainer" parent="StatsPanel"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 280.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 784.0
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="CoverEditorTitle" type="Label" parent="StatsPanel/CoverEditor"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Title"
|
||||
text = "Cover"
|
||||
|
||||
[node name="CoverEditorHelp" type="Label" parent="StatsPanel/CoverEditor"]
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
text = "The server finds covers by itself. When it found the wrong one, or none, choose a picture of your own or the cover of a game on SteamGridDB or an anime on AniList. The server keeps a cover set here until it is removed."
|
||||
|
||||
[node name="PictureRow" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="ChoosePictureButton" type="Button" parent="StatsPanel/CoverEditor/PictureRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Choose picture…"
|
||||
|
||||
[node name="RemoveCoverButton" type="Button" parent="StatsPanel/CoverEditor/PictureRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Remove cover"
|
||||
|
||||
[node name="SourceTitle" type="Label" parent="StatsPanel/CoverEditor"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Pick by ID"
|
||||
|
||||
[node name="SourceHelp" type="Label" parent="StatsPanel/CoverEditor"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"TextMedium"
|
||||
autowrap_mode = 3
|
||||
text = "The ID is the number in the game's or anime's web address."
|
||||
|
||||
[node name="SourceRow" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="CoverSourceButton" type="OptionButton" parent="StatsPanel/CoverEditor/SourceRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
selected = 0
|
||||
item_count = 2
|
||||
popup/item_0/text = "SteamGridDB"
|
||||
popup/item_0/id = 0
|
||||
popup/item_1/text = "AniList"
|
||||
popup/item_1/id = 1
|
||||
|
||||
[node name="CoverIdField" type="LineEdit" parent="StatsPanel/CoverEditor/SourceRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "ID"
|
||||
max_length = 50
|
||||
|
||||
[node name="UseSourceButton" type="Button" parent="StatsPanel/CoverEditor/SourceRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Use"
|
||||
|
||||
[node name="CoverEditorSpace" type="Control" parent="StatsPanel/CoverEditor"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="CoverStatus" type="Label" parent="StatsPanel/CoverEditor"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="CoverEditorButtons" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
|
||||
layout_mode = 2
|
||||
alignment = 2
|
||||
|
||||
[node name="CloseCoverButton" type="Button" parent="StatsPanel/CoverEditor/CoverEditorButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Done"
|
||||
|
||||
[node name="PictureDialog" type="FileDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Choose a cover picture"
|
||||
initial_position = 2
|
||||
size = Vector2i(800, 500)
|
||||
ok_button_text = "Open"
|
||||
file_mode = 0
|
||||
access = 2
|
||||
filters = PackedStringArray("*.jpg, *.jpeg, *.png, *.webp ; Pictures")
|
||||
use_native_dialog = true
|
||||
@@ -1,6 +1,7 @@
|
||||
[gd_scene format=3 uid="uid://dpdem7pdxweb5"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://c3ejukwth512a" path="res://popups/sync_popup.gd" id="1_8ba47"]
|
||||
[ext_resource type="Script" path="res://common/animated_progress_bar.gd" id="progress_bar_script"]
|
||||
|
||||
[node name="SyncPopup" type="Control" unique_id=1302914356 node_paths=PackedStringArray("panel")]
|
||||
layout_mode = 3
|
||||
@@ -62,11 +63,11 @@ layout_mode = 2
|
||||
|
||||
[node name="SyncProgressBar" type="ProgressBar" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer" unique_id=719135231]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(1300, 0)
|
||||
custom_minimum_size = Vector2(1300, 32)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 6
|
||||
step = 1.0
|
||||
rounded = true
|
||||
theme_type_variation = &"SyncProgressBar"
|
||||
script = ExtResource("progress_bar_script")
|
||||
|
||||
[node name="SyncHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=1156646398]
|
||||
unique_name_in_owner = true
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://popups/update_popup.gd" id="popup_script"]
|
||||
[ext_resource type="Script" path="res://common/updater.gd" id="updater_script"]
|
||||
|
||||
[node name="UpdatePopup" type="Control" node_paths=PackedStringArray("panel")]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("popup_script")
|
||||
panel = NodePath("UpdatePanel")
|
||||
|
||||
[node name="UpdatePanel" type="Panel" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_right = 640.0
|
||||
offset_bottom = 300.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="Rows" type="VBoxContainer" parent="UpdatePanel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 24.0
|
||||
offset_top = 16.0
|
||||
offset_right = 616.0
|
||||
offset_bottom = 284.0
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="UpdatePanel/Rows"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Title"
|
||||
text = "Version 0.0.0 is available"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="InfoLabel" type="Label" parent="UpdatePanel/Rows"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="ReasonLabel" type="Label" parent="UpdatePanel/Rows"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="DownloadProgress" type="ProgressBar" parent="UpdatePanel/Rows"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
max_value = 1.0
|
||||
show_percentage = false
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="UpdatePanel/Rows"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="Space" type="Control" parent="UpdatePanel/Rows"]
|
||||
custom_minimum_size = Vector2(0, 4)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Buttons" type="HBoxContainer" parent="UpdatePanel/Rows"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 2
|
||||
|
||||
[node name="NotNowButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Not now"
|
||||
|
||||
[node name="BrowserButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Download in browser"
|
||||
|
||||
[node name="UpdateNowButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Update and restart"
|
||||
|
||||
[node name="Updater" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("updater_script")
|
||||
+100
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
[ext_resource type="Script" uid="uid://dxpb22xbwf3t1" path="res://popups/winner_popup.gd" id="1_winner_script"]
|
||||
[ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="2_no_character"]
|
||||
[ext_resource type="Script" path="res://popups/score_graph.gd" id="3_score_graph"]
|
||||
|
||||
[node name="WinnerPopup" type="Control" unique_id=1111111111 node_paths=PackedStringArray("panel")]
|
||||
layout_mode = 3
|
||||
@@ -16,37 +17,120 @@ panel = NodePath("WinnerPanel")
|
||||
[node name="WinnerPanel" type="Panel" parent="." unique_id=89212869]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_right = 500.0
|
||||
offset_bottom = 600.0
|
||||
offset_right = 1600.0
|
||||
offset_bottom = 920.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="WinnerPanel" unique_id=471159349]
|
||||
[node name="GameColumn" type="VBoxContainer" parent="WinnerPanel" unique_id=471159351]
|
||||
layout_mode = 0
|
||||
offset_top = 25.0
|
||||
offset_right = 500.0
|
||||
offset_bottom = 282.0
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
alignment = 1
|
||||
offset_left = 24.0
|
||||
offset_top = 20.0
|
||||
offset_right = 1576.0
|
||||
offset_bottom = 900.0
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="WinnerPicture" type="TextureRect" parent="WinnerPanel/VBoxContainer" unique_id=1543774067]
|
||||
[node name="Header" type="HBoxContainer" parent="WinnerPanel/GameColumn" unique_id=471159349]
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
theme_override_constants/separation = 32
|
||||
|
||||
[node name="WinnerPicture" type="TextureRect" parent="WinnerPanel/GameColumn/Header" unique_id=1543774067]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(480, 200)
|
||||
custom_minimum_size = Vector2(360, 180)
|
||||
layout_mode = 2
|
||||
texture = ExtResource("2_no_character")
|
||||
expand_mode = 3
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="WinnerLabel" type="Label" parent="WinnerPanel/VBoxContainer" unique_id=1437597286]
|
||||
[node name="WinnerInfo" type="VBoxContainer" parent="WinnerPanel/GameColumn/Header" unique_id=471159367]
|
||||
custom_minimum_size = Vector2(520, 0)
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="WinnerLabel" type="Label" parent="WinnerPanel/GameColumn/Header/WinnerInfo" unique_id=1437597286]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"TitleMedium"
|
||||
text = "Winner!"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 2
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="PlayersList" type="VBoxContainer" parent="WinnerPanel/VBoxContainer" unique_id=471159350]
|
||||
[node name="PlayersList" type="VBoxContainer" parent="WinnerPanel/GameColumn/Header/WinnerInfo" unique_id=471159350]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Legend" type="HBoxContainer" parent="WinnerPanel/GameColumn" unique_id=471159352]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="ScoreGraph" type="Control" parent="WinnerPanel/GameColumn" unique_id=471159353]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 2
|
||||
script = ExtResource("3_score_graph")
|
||||
|
||||
[node name="Summary" type="HBoxContainer" parent="WinnerPanel/GameColumn" unique_id=471159354]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="StreakBox" type="PanelContainer" parent="WinnerPanel/GameColumn/Summary" unique_id=471159355]
|
||||
custom_minimum_size = Vector2(0, 84)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"ListRowEven"
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="WinnerPanel/GameColumn/Summary/StreakBox" unique_id=471159356]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Header" type="Label" parent="WinnerPanel/GameColumn/Summary/StreakBox/VBox" unique_id=471159357]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Longest streak"
|
||||
|
||||
[node name="StreakLabel" type="Label" parent="WinnerPanel/GameColumn/Summary/StreakBox/VBox" unique_id=471159358]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(1, 0)
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="StandstillBox" type="PanelContainer" parent="WinnerPanel/GameColumn/Summary" unique_id=471159359]
|
||||
custom_minimum_size = Vector2(0, 84)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"ListRowEven"
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="WinnerPanel/GameColumn/Summary/StandstillBox" unique_id=471159360]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Header" type="Label" parent="WinnerPanel/GameColumn/Summary/StandstillBox/VBox" unique_id=471159361]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Longest standstill"
|
||||
|
||||
[node name="StandstillLabel" type="Label" parent="WinnerPanel/GameColumn/Summary/StandstillBox/VBox" unique_id=471159362]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(1, 0)
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="SharedBox" type="PanelContainer" parent="WinnerPanel/GameColumn/Summary" unique_id=471159363]
|
||||
custom_minimum_size = Vector2(0, 84)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"ListRowEven"
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="WinnerPanel/GameColumn/Summary/SharedBox" unique_id=471159364]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Header" type="Label" parent="WinnerPanel/GameColumn/Summary/SharedBox/VBox" unique_id=471159365]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Most shared song"
|
||||
|
||||
[node name="SharedLabel" type="Label" parent="WinnerPanel/GameColumn/Summary/SharedBox/VBox" unique_id=471159366]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(1, 0)
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
+37
-7
@@ -1,6 +1,7 @@
|
||||
class_name AboutPopup
|
||||
extends BasePopup
|
||||
## Shows the game version and what is new in each version.
|
||||
## Shows the game's and the server's version, and what is new in each of their versions,
|
||||
## side by side.
|
||||
|
||||
## What is new in each version, newest first.
|
||||
const WHATS_NEW: Dictionary = {
|
||||
@@ -57,16 +58,45 @@ const WHATS_NEW: Dictionary = {
|
||||
@onready var about_panel: Panel = %AboutPanel
|
||||
@onready var version_label: Label = %VersionLabel
|
||||
@onready var whats_new_label: Label = %WhatsNewLabel
|
||||
@onready var server_version_label: Label = %ServerVersionLabel
|
||||
@onready var server_history_label: Label = %ServerHistoryLabel
|
||||
|
||||
|
||||
func open() -> void:
|
||||
super()
|
||||
version_label.text = Settings.version
|
||||
|
||||
# Build changelog text with version headers
|
||||
var changelog_text: String = ""
|
||||
var entries: Array = []
|
||||
for version: String in WHATS_NEW.keys():
|
||||
changelog_text += "\n" + version + "\n"
|
||||
changelog_text += WHATS_NEW[version] + "\n"
|
||||
entries.append([version, [WHATS_NEW[version]]])
|
||||
whats_new_label.text = changelog_text(entries)
|
||||
|
||||
whats_new_label.text = changelog_text
|
||||
# Asked every time, as another server can be picked in the settings
|
||||
server_history_label.text = ""
|
||||
if Settings.is_offline():
|
||||
server_version_label.text = "Not connected, playing from a music folder"
|
||||
return
|
||||
server_version_label.text = "Loading…"
|
||||
MusicServerClient.fetch_version_history(_show_server_history, _show_server_history_failed)
|
||||
|
||||
|
||||
## The changelog text for [param entries], each a [code][version, lines][/code] pair, newest
|
||||
## first: every version on its own line, followed by its lines, with an empty line
|
||||
## between versions.
|
||||
static func changelog_text(entries: Array) -> String:
|
||||
var versions: PackedStringArray = []
|
||||
for entry: Array in entries:
|
||||
versions.append(entry[0] + "\n" + "\n".join(entry[1]))
|
||||
return "\n\n".join(versions)
|
||||
|
||||
|
||||
func _show_server_history(history: Array[VersionData]) -> void:
|
||||
server_version_label.text = history[0].version if !history.is_empty() else ""
|
||||
var entries: Array = []
|
||||
for version: VersionData in history:
|
||||
entries.append([version.version, version.changelog])
|
||||
server_history_label.text = changelog_text(entries)
|
||||
|
||||
|
||||
func _show_server_history_failed() -> void:
|
||||
server_version_label.text = "Could not reach the server"
|
||||
server_history_label.text = ""
|
||||
+51
-4
@@ -4,32 +4,79 @@ extends Control
|
||||
##
|
||||
## Clicking outside [member panel] closes it. Popups that need to prepare or clean up
|
||||
## override [method open] or [method close] and call [code]super()[/code].
|
||||
##
|
||||
## The panel fades in while growing from [constant START_SCALE] of its size, and closing
|
||||
## does the reverse; the popup is hidden once that has finished. See [Anim].
|
||||
|
||||
## Emitted when the popup opens, also when it is opened again while closing.
|
||||
signal opening
|
||||
## Emitted when the popup starts closing, before it is hidden, so the backdrop can fade
|
||||
## out with it.
|
||||
signal closing
|
||||
|
||||
## How big the panel starts when it opens, and ends when it closes.
|
||||
const START_SCALE: float = 0.95
|
||||
|
||||
## The visible part of the popup. Clicks outside it close the popup.
|
||||
|
||||
@export var panel: Control
|
||||
|
||||
## True while the popup is fading out after [method close].
|
||||
var is_closing: bool = false
|
||||
|
||||
|
||||
## Shows the popup.
|
||||
func open() -> void:
|
||||
is_closing = false
|
||||
visible = true
|
||||
opening.emit()
|
||||
var open_tween: Tween = Anim.tween(panel, &"popup")
|
||||
panel.modulate.a = 1.0
|
||||
panel.scale = Vector2.ONE
|
||||
if open_tween == null:
|
||||
return
|
||||
Anim.center_pivot(panel)
|
||||
panel.modulate.a = 0.0
|
||||
panel.scale = Vector2.ONE * START_SCALE
|
||||
open_tween.set_parallel()
|
||||
open_tween.tween_property(panel, "modulate:a", 1.0, Anim.NORMAL)
|
||||
open_tween.tween_property(panel, "scale", Vector2.ONE, Anim.NORMAL) \
|
||||
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
|
||||
|
||||
## Hides the popup.
|
||||
func close() -> void:
|
||||
visible = false
|
||||
if !visible || is_closing:
|
||||
return
|
||||
is_closing = true
|
||||
closing.emit()
|
||||
var close_tween: Tween = Anim.tween(panel, &"popup")
|
||||
if close_tween == null:
|
||||
_hide()
|
||||
return
|
||||
Anim.center_pivot(panel)
|
||||
close_tween.set_parallel()
|
||||
close_tween.tween_property(panel, "modulate:a", 0.0, Anim.FAST)
|
||||
close_tween.tween_property(panel, "scale", Vector2.ONE * START_SCALE, Anim.FAST)
|
||||
close_tween.chain().tween_callback(_hide)
|
||||
|
||||
|
||||
## Opens the popup if it is closed and closes it if it is open.
|
||||
func toggle() -> void:
|
||||
if visible:
|
||||
if visible && !is_closing:
|
||||
close()
|
||||
else:
|
||||
open()
|
||||
|
||||
|
||||
func _hide() -> void:
|
||||
is_closing = false
|
||||
visible = false
|
||||
panel.modulate.a = 1.0
|
||||
panel.scale = Vector2.ONE
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if !visible || !(event is InputEventMouseButton) || !event.pressed:
|
||||
if !visible || is_closing || !(event is InputEventMouseButton) || !event.pressed:
|
||||
return
|
||||
var click: InputEventMouseButton = panel.make_input_local(event)
|
||||
if !Rect2(Vector2.ZERO, panel.size).has_point(click.position):
|
||||
|
||||
+54
-37
@@ -1,48 +1,65 @@
|
||||
class_name CharacterCard
|
||||
extends Control
|
||||
extends Panel
|
||||
## One character in the character select popup: its picture and name. Clicking it picks
|
||||
## the character.
|
||||
|
||||
## Emitted with the character's picture when the card is clicked.
|
||||
## Emitted with the character's picture and name when the card is clicked.
|
||||
signal new_character_selected(img_texture: ImageTexture, character_name: String)
|
||||
|
||||
signal new_character_selected(img_texture: ImageTexture)
|
||||
## The character's name: its picture's file name, e.g. "PaperMario.png".
|
||||
var character_name: String = ""
|
||||
|
||||
@onready var panel: Panel = $Panel
|
||||
@onready var character_image: TextureRect = %CharacterTextureRect
|
||||
@onready var character_name_label: Label = %CharacterName
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if visible && Settings.character_select_open:
|
||||
if event is InputEventMouseButton && event.is_pressed() \
|
||||
&& event.get_button_index() == MOUSE_BUTTON_LEFT:
|
||||
var local_event: InputEvent = make_input_local(event)
|
||||
if Rect2(Vector2(0, 0), panel.size).has_point(local_event.position):
|
||||
func _ready() -> void:
|
||||
gui_input.connect(_on_gui_input)
|
||||
mouse_entered.connect(_set_hovered.bind(true))
|
||||
mouse_exited.connect(_set_hovered.bind(false))
|
||||
|
||||
|
||||
## Shows [param character_name] and its picture. [param fetch_image] is called with the
|
||||
## name and a Callable that takes the picture as an [Image], e.g.
|
||||
## [method CharacterCache.fetch_image].
|
||||
func set_character_name(new_name: String, fetch_image: Callable) -> void:
|
||||
character_name = new_name
|
||||
character_name_label.text = display_name(new_name)
|
||||
tooltip_text = character_name_label.text
|
||||
var image_fetched: Callable = func image_fetched(picture: Image) -> void:
|
||||
character_image.texture = ImageTexture.create_from_image(picture)
|
||||
fetch_image.call(new_name, image_fetched)
|
||||
|
||||
|
||||
## The name to show for a picture's [param file_name]: without the file extension, and
|
||||
## with words that are written together split, e.g. "PaperMario.png" is "Paper Mario".
|
||||
static func display_name(file_name: String) -> String:
|
||||
var name_part: String = file_name.get_basename() if "." in file_name else file_name
|
||||
var words: RegEx = RegEx.create_from_string("([a-z])([A-Z])")
|
||||
return words.sub(name_part, "$1 $2", true)
|
||||
|
||||
|
||||
## Reads a PNG, JPG or WebP picture, telling them apart by their first bytes so that no
|
||||
## error is printed for trying the wrong format. Returns null if it is none of them.
|
||||
static func image_from_bytes(bytes: PackedByteArray) -> Image:
|
||||
var image: Image = Image.new()
|
||||
var error: Error = ERR_FILE_UNRECOGNIZED
|
||||
if bytes.size() >= 4 && bytes.slice(0, 4) == PackedByteArray([0x89, 0x50, 0x4E, 0x47]):
|
||||
error = image.load_png_from_buffer(bytes)
|
||||
elif bytes.size() >= 3 && bytes.slice(0, 3) == PackedByteArray([0xFF, 0xD8, 0xFF]):
|
||||
error = image.load_jpg_from_buffer(bytes)
|
||||
elif bytes.size() >= 12 && bytes.slice(8, 12).get_string_from_ascii() == "WEBP":
|
||||
error = image.load_webp_from_buffer(bytes)
|
||||
return image if error == OK else null
|
||||
|
||||
|
||||
func _set_hovered(hovered: bool) -> void:
|
||||
theme_type_variation = &"CharacterCardHover" if hovered else &"CharacterCard"
|
||||
|
||||
|
||||
func _on_gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton && event.pressed \
|
||||
&& event.button_index == MOUSE_BUTTON_LEFT && Settings.character_select_open:
|
||||
accept_event()
|
||||
GameLog.debug(character_name_label.text)
|
||||
new_character_selected.emit(character_image.texture)
|
||||
|
||||
|
||||
## Shows [param character_name] and fetches its picture from the server.
|
||||
func set_character_name(character_name: String) -> void:
|
||||
panel.size = Vector2(240, 145)
|
||||
character_name_label.text = character_name.split(".")[0]
|
||||
character_name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
var image_fetched: Callable = func image_fetched(image: PackedByteArray) -> void:
|
||||
var img: Image = Image.new()
|
||||
var err: Error = img.load_png_from_buffer(image)
|
||||
if err != OK:
|
||||
GameLog.debug("Character image is not a PNG, trying JPG: ", error_string(err))
|
||||
var err2: Error = img.load_jpg_from_buffer(image)
|
||||
if err2 != OK:
|
||||
GameLog.warning("Could not load the image for ", character_name, ": ",
|
||||
error_string(err2))
|
||||
var new_image: ImageTexture = ImageTexture.new()
|
||||
new_image.set_image(img)
|
||||
character_image.custom_minimum_size = Vector2(200, 100)
|
||||
character_image.size = Vector2(200, 100)
|
||||
character_image.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
character_image.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
character_image.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
|
||||
character_image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
character_image.texture = new_image
|
||||
MusicServerClient.fetch_character_image(character_name, image_fetched)
|
||||
new_character_selected.emit(character_image.texture, character_name)
|
||||
@@ -1,11 +1,24 @@
|
||||
class_name CharacterSelectPopup
|
||||
extends BasePopup
|
||||
## The characters players pick from, from the server or from a folder on this computer
|
||||
## (see [method Settings.characters_dir]).
|
||||
|
||||
## Emitted with the picture of the character that was picked.
|
||||
signal character_selected(texture: ImageTexture)
|
||||
## Emitted with the picture and name of the character that was picked.
|
||||
signal character_selected(texture: ImageTexture, character_name: String)
|
||||
|
||||
## Emitted when the characters come from somewhere else than before, e.g. a folder was
|
||||
## picked. Characters picked before are not in the new set.
|
||||
signal source_changed
|
||||
|
||||
var can_select_character: bool = false
|
||||
var character: PackedScene = preload("res://popups/CharacterCard.tscn")
|
||||
## Keeps the pictures on disk, so only changed ones are downloaded.
|
||||
var character_cache: CharacterCache = CharacterCache.new()
|
||||
## Where the characters shown come from: [member character_cache] or a [FolderCharacters].
|
||||
var source: RefCounted = character_cache
|
||||
## The folder the characters shown come from, or empty for the server. Null before the
|
||||
## characters were first loaded.
|
||||
var source_dir: Variant = null
|
||||
|
||||
@onready var character_panel: Panel = %CharacterPanel
|
||||
@onready var title_label: Label = %TitleLabel
|
||||
@@ -23,21 +36,30 @@ func _on_close_timeout() -> void:
|
||||
close_timer.stop()
|
||||
|
||||
|
||||
## Loads the characters from where [method Settings.characters_dir] says. Emits
|
||||
## [signal source_changed] when that is somewhere else than last time.
|
||||
func load_characters() -> void:
|
||||
GameLog.debug("load_characters")
|
||||
var fetch_character_list: Callable = func fetch_character_list(list: Variant) -> void:
|
||||
GameLog.debug("fetch_character_list")
|
||||
var dir: String = Settings.characters_dir()
|
||||
var changed: bool = source_dir != null && source_dir != dir
|
||||
source_dir = dir
|
||||
source = character_cache if dir.is_empty() else FolderCharacters.new(dir)
|
||||
var loading: RefCounted = source
|
||||
var list_loaded: Callable = func list_loaded(names: Array[String]) -> void:
|
||||
GameLog.debug("list_loaded")
|
||||
# The source changed again before the server answered
|
||||
if source != loading:
|
||||
return
|
||||
Settings.delete_children(character_grid)
|
||||
if typeof(list) == TYPE_ARRAY:
|
||||
for character_name: String in list:
|
||||
for character_name: String in names:
|
||||
GameLog.debug("character_name ", character_name)
|
||||
var character_box: CharacterCard = character.instantiate()
|
||||
character_grid.add_child(character_box)
|
||||
character_box.custom_minimum_size = Vector2(235, 145)
|
||||
character_box.size = Vector2(235, 145)
|
||||
character_box.set_character_name(character_name)
|
||||
character_box.set_character_name(character_name, loading.fetch_image)
|
||||
character_box.new_character_selected.connect(_on_character_selected)
|
||||
MusicServerClient.fetch_character_list(fetch_character_list)
|
||||
loading.load_list(list_loaded)
|
||||
if changed:
|
||||
source_changed.emit()
|
||||
|
||||
|
||||
func show_grid(player_name: String) -> void:
|
||||
@@ -54,12 +76,12 @@ func close() -> void:
|
||||
Settings.character_select_open = false
|
||||
|
||||
|
||||
func _on_character_selected(texture: ImageTexture) -> void:
|
||||
func _on_character_selected(texture: ImageTexture, character_name: String) -> void:
|
||||
GameLog.debug("_on_character_selected")
|
||||
# Not close(): character_select_open stays true until close_timer fires, so the click
|
||||
# that picked the character does not also select a song behind it
|
||||
visible = false
|
||||
if can_select_character:
|
||||
character_selected.emit(texture)
|
||||
character_selected.emit(texture, character_name)
|
||||
close_timer.start()
|
||||
can_select_character = false
|
||||
+24
-14
@@ -1,28 +1,38 @@
|
||||
class_name LogPopup
|
||||
extends BasePopup
|
||||
## The game log: a row with the time for each thing that happens in the quiz, such as a
|
||||
## player getting a point. Opened with Alt+L.
|
||||
|
||||
var log_rows: Array
|
||||
## player getting a point. Opened with Alt+L. The newest row is at the bottom and is
|
||||
## scrolled into view.
|
||||
|
||||
@onready var log_panel: Panel = %LogPanel
|
||||
@onready var log_box: VBoxContainer = %LogVBoxContainer
|
||||
@onready var log_scroll: ScrollContainer = %LogScrollContainer
|
||||
@onready var empty_label: Label = %EmptyLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
visibility_changed.connect(_on_visibility_changed)
|
||||
|
||||
|
||||
func add_log_row(text: String) -> void:
|
||||
var log_row: String = get_time() + text
|
||||
log_rows.append(log_row)
|
||||
log_box.add_child(make_log_label(log_row))
|
||||
|
||||
|
||||
func make_log_label(row: String) -> Label:
|
||||
var log_label: Label = Label.new()
|
||||
log_label.theme_type_variation = &"TextLarge"
|
||||
log_label.text = row
|
||||
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
return log_label
|
||||
var row: int = log_box.get_child_count()
|
||||
log_box.add_child(ListRow.create(get_time() + text, row, &"TextLarge"))
|
||||
empty_label.visible = false
|
||||
_scroll_to_newest.call_deferred()
|
||||
|
||||
|
||||
func get_time() -> String:
|
||||
var time: Dictionary = Time.get_time_dict_from_system()
|
||||
return "%02d:%02d:%02d " % [time.hour, time.minute, time.second]
|
||||
|
||||
|
||||
func _on_visibility_changed() -> void:
|
||||
if visible:
|
||||
empty_label.visible = log_box.get_child_count() == 0
|
||||
_scroll_to_newest.call_deferred()
|
||||
|
||||
|
||||
# Deferred, as the list only knows its new height once the new row is laid out
|
||||
func _scroll_to_newest() -> void:
|
||||
await get_tree().process_frame
|
||||
log_scroll.scroll_vertical = int(log_scroll.get_v_scroll_bar().max_value)
|
||||
@@ -23,9 +23,38 @@ static func add_to(parent: Control, popups: Array[BasePopup]) -> PopupBackdrop:
|
||||
for popup: BasePopup in popups:
|
||||
parent.move_child(popup, -1)
|
||||
popup.visibility_changed.connect(backdrop._update)
|
||||
popup.closing.connect(backdrop._on_popup_closing)
|
||||
popup.opening.connect(backdrop._on_popup_opening)
|
||||
backdrop._update()
|
||||
return backdrop
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
# The dimming colour comes from the theme, which can be switched in Settings
|
||||
if what == NOTIFICATION_THEME_CHANGED:
|
||||
color = get_theme_color(&"backdrop", &"Palette")
|
||||
|
||||
|
||||
func _update() -> void:
|
||||
var was_visible: bool = visible
|
||||
visible = _popups.any(func(popup: BasePopup) -> bool: return popup.visible)
|
||||
if visible && !was_visible:
|
||||
Anim.fade_in(self)
|
||||
|
||||
|
||||
# A popup opened again while closing stops the backdrop fading out
|
||||
func _on_popup_opening() -> void:
|
||||
var fade_tween: Tween = Anim.tween(self, &"fade")
|
||||
if fade_tween == null || modulate.a >= 1.0:
|
||||
modulate.a = 1.0
|
||||
return
|
||||
fade_tween.tween_property(self, "modulate:a", 1.0, Anim.FAST)
|
||||
|
||||
|
||||
# Fades out with the last open popup, which hides the backdrop when it has closed
|
||||
func _on_popup_closing() -> void:
|
||||
if _popups.any(func(popup: BasePopup) -> bool: return popup.visible && !popup.is_closing):
|
||||
return
|
||||
var fade_tween: Tween = Anim.tween(self, &"fade")
|
||||
if fade_tween != null:
|
||||
fade_tween.tween_property(self, "modulate:a", 0.0, Anim.FAST)
|
||||
+10
-13
@@ -9,21 +9,18 @@ extends BasePopup
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
qr_code_rect.light_module_color = Color.WHITE
|
||||
qr_code_rect.dark_module_color = Color.BLACK
|
||||
qr_code_rect.quiet_zone_size = 4
|
||||
|
||||
# Generate QR code for the search URL
|
||||
var url: String = Settings.default_path + "/search"
|
||||
qr_code_rect.data = url
|
||||
url_label.text = qr_code_rect.data
|
||||
GameLog.debug("QR code generated for: ", url)
|
||||
_show_search_url()
|
||||
|
||||
|
||||
func open() -> void:
|
||||
super()
|
||||
# Regenerate QR code in case settings changed
|
||||
var url: String = Settings.default_path + "/search"
|
||||
qr_code_rect.data = url
|
||||
url_label.text = qr_code_rect.data
|
||||
# Another server may have been picked in the settings since last time
|
||||
_show_search_url()
|
||||
|
||||
|
||||
func _show_search_url() -> void:
|
||||
var url: String = Settings.default_path + "/search"
|
||||
if qr_code_rect.data != url:
|
||||
qr_code_rect.data = url
|
||||
GameLog.debug("QR code generated for: ", url)
|
||||
url_label.text = url
|
||||
@@ -0,0 +1,206 @@
|
||||
class_name ScoreGraph
|
||||
extends Control
|
||||
## Draws every player's score after each song of the game as a step line, so streaks and
|
||||
## stretches where nobody scored stand out. Shown on the Winner popup.
|
||||
##
|
||||
## Colours and sizes come from the theme's [code]ScoreGraph[/code] type. Each player gets
|
||||
## the colour of their position in [code]Settings.player_array[/code].
|
||||
|
||||
const THEME_TYPE: StringName = &"ScoreGraph"
|
||||
## Room for the score labels on the left and right, and the song labels below.
|
||||
const MARGIN_LEFT: float = 32.0
|
||||
const MARGIN_RIGHT: float = 32.0
|
||||
const MARGIN_TOP: float = 22.0
|
||||
const MARGIN_BOTTOM: float = 44.0
|
||||
## About how many song numbers fit along the bottom.
|
||||
const MAX_SONG_LABELS: int = 20
|
||||
|
||||
## How much of the lines is drawn, from 0 (none) to 1 (all), so they can be drawn from
|
||||
## left to right.
|
||||
var progress: float = 1.0:
|
||||
set(value):
|
||||
progress = value
|
||||
queue_redraw()
|
||||
|
||||
var _history: ScoreHistory
|
||||
var _winning_score: int = 0
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
# The colours come from the theme, which can be switched in Settings
|
||||
if what == NOTIFICATION_THEME_CHANGED:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Shows [param history]. The y axis goes up to [param winning_score], or higher if
|
||||
## someone has more points. A winning score of 0 draws no winning line.
|
||||
func show_history(history: ScoreHistory, winning_score: int) -> void:
|
||||
_history = history
|
||||
_winning_score = winning_score
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Draws the lines from left to right over [param duration] seconds.
|
||||
func draw_lines_in(duration: float = 1.2) -> void:
|
||||
var draw_tween: Tween = Anim.tween(self, &"draw")
|
||||
progress = 1.0
|
||||
if draw_tween == null:
|
||||
return
|
||||
progress = 0.0
|
||||
draw_tween.tween_property(self, "progress", 1.0, duration) \
|
||||
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
|
||||
|
||||
## The part of [param points] left of [param limit_x], cut where the line crosses it.
|
||||
static func clip_line(points: PackedVector2Array, limit_x: float) -> PackedVector2Array:
|
||||
var clipped: PackedVector2Array = []
|
||||
for i: int in points.size():
|
||||
if points[i].x <= limit_x:
|
||||
clipped.append(points[i])
|
||||
continue
|
||||
if i > 0 && points[i - 1].x < limit_x:
|
||||
var before: Vector2 = points[i - 1]
|
||||
var weight: float = (limit_x - before.x) / (points[i].x - before.x)
|
||||
clipped.append(before.lerp(points[i], weight))
|
||||
break
|
||||
return clipped
|
||||
|
||||
|
||||
## The colour of the player at [param index] in the player list.
|
||||
func player_color(index: int) -> Color:
|
||||
return get_theme_color(StringName("player_%d" % (index % 7 + 1)), THEME_TYPE)
|
||||
|
||||
|
||||
## Where the line of the player at [param index] is drawn after each song, starting
|
||||
## before the first song.
|
||||
func line_points(index: int) -> PackedVector2Array:
|
||||
var points: PackedVector2Array = []
|
||||
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
|
||||
points.append(_point(0, 0, index))
|
||||
for song: int in range(1, scores.size()):
|
||||
if scores[song] != scores[song - 1]:
|
||||
points.append(_point(song, scores[song - 1], index))
|
||||
points.append(_point(song, scores[song], index))
|
||||
points.append(_point(scores.size() - 1, scores.back(), index))
|
||||
return points
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if _history == null:
|
||||
return
|
||||
var font: Font = get_theme_default_font()
|
||||
var font_size: int = get_theme_font_size(&"font_size", THEME_TYPE)
|
||||
var text_color: Color = get_theme_color(&"axis_text", THEME_TYPE)
|
||||
_draw_score_axis(font, font_size, text_color)
|
||||
_draw_song_axis(font, font_size, text_color)
|
||||
|
||||
var line_width: int = get_theme_constant(&"line_width", THEME_TYPE)
|
||||
var dot_radius: int = get_theme_constant(&"dot_radius", THEME_TYPE)
|
||||
var limit_x: float = lerpf(MARGIN_LEFT, size.x - MARGIN_RIGHT, progress)
|
||||
for index: int in _history.players.size():
|
||||
var color: Color = player_color(index)
|
||||
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
|
||||
var points: PackedVector2Array = clip_line(line_points(index), limit_x)
|
||||
if points.size() >= 2:
|
||||
draw_polyline(points, color, line_width, true)
|
||||
for song: int in range(1, scores.size()):
|
||||
var dot: Vector2 = _point(song, scores[song], index)
|
||||
if scores[song] > scores[song - 1] && dot.x <= limit_x:
|
||||
draw_circle(dot, dot_radius, color)
|
||||
# The final scores are written once the lines have reached them
|
||||
if progress < 1.0:
|
||||
return
|
||||
var label_positions: Array[Vector2] = score_label_positions()
|
||||
for index: int in _history.players.size():
|
||||
draw_string(font, label_positions[index],
|
||||
str(_history.final_score(_history.players[index].id)),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, player_color(index))
|
||||
|
||||
|
||||
## Where each player's score is written at the end of their line, as the baseline of the
|
||||
## text. Scores that would overlap are moved apart.
|
||||
func score_label_positions() -> Array[Vector2]:
|
||||
var font_size: int = get_theme_font_size(&"font_size", THEME_TYPE)
|
||||
var positions: Array[Vector2] = []
|
||||
for index: int in _history.players.size():
|
||||
var score: int = _history.final_score(_history.players[index].id)
|
||||
var end: Vector2 = _point(_history.songs.size(), score, index)
|
||||
positions.append(end + Vector2(8, font_size * 0.35))
|
||||
# From the top down, push each label below the one above it
|
||||
var order: Array = range(positions.size())
|
||||
order.sort_custom(func(a: int, b: int) -> bool: return positions[a].y < positions[b].y)
|
||||
for i: int in range(1, order.size()):
|
||||
var above: Vector2 = positions[order[i - 1]]
|
||||
if positions[order[i]].y - above.y < font_size:
|
||||
positions[order[i]].y = above.y + font_size
|
||||
return positions
|
||||
|
||||
|
||||
## The songs whose numbers are written along the bottom.
|
||||
func labelled_songs() -> Array[int]:
|
||||
var song_count: int = _history.songs.size()
|
||||
var step: int = maxi(1, ceili(song_count / float(MAX_SONG_LABELS)))
|
||||
var songs: Array[int] = []
|
||||
for song: int in range(1, song_count + 1):
|
||||
# The last song is always labelled, unless that would crowd its neighbour
|
||||
var is_last: bool = song == song_count && (song - 1) % step >= step / 2.0
|
||||
if (song - 1) % step == 0 || is_last:
|
||||
songs.append(song)
|
||||
return songs
|
||||
|
||||
|
||||
## Where on the x axis [param song] is, counting the start of the game as song 0.
|
||||
func song_x(song: int) -> float:
|
||||
return _x(song)
|
||||
|
||||
|
||||
func _draw_score_axis(font: Font, font_size: int, text_color: Color) -> void:
|
||||
var grid_color: Color = get_theme_color(&"grid", THEME_TYPE)
|
||||
var top: int = _top_score()
|
||||
var step: int = maxi(1, ceili(top / 10.0))
|
||||
for score: int in range(0, top + 1, step):
|
||||
var y: float = _y(score)
|
||||
draw_line(Vector2(MARGIN_LEFT, y), Vector2(size.x - MARGIN_RIGHT, y), grid_color, 1)
|
||||
draw_string(font, Vector2(0, y + font_size * 0.35), str(score),
|
||||
HORIZONTAL_ALIGNMENT_RIGHT, MARGIN_LEFT - 8, font_size, text_color)
|
||||
if _winning_score <= 0:
|
||||
return
|
||||
var winning_color: Color = get_theme_color(&"winning_line", THEME_TYPE)
|
||||
var winning_y: float = _y(_winning_score)
|
||||
draw_dashed_line(Vector2(MARGIN_LEFT, winning_y), Vector2(size.x - MARGIN_RIGHT, winning_y),
|
||||
winning_color, 1, 6)
|
||||
draw_string(font, Vector2(MARGIN_LEFT + 4, winning_y - 6), "winning score",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, winning_color)
|
||||
|
||||
|
||||
func _draw_song_axis(font: Font, font_size: int, text_color: Color) -> void:
|
||||
var label_y: float = size.y - MARGIN_BOTTOM + font_size + 6
|
||||
for song: int in labelled_songs():
|
||||
draw_string(font, Vector2(_x(song) - 20, label_y), str(song),
|
||||
HORIZONTAL_ALIGNMENT_CENTER, 40, font_size, text_color)
|
||||
draw_string(font, Vector2(0, size.y - 4), "Song", HORIZONTAL_ALIGNMENT_CENTER, size.x,
|
||||
font_size, text_color)
|
||||
|
||||
|
||||
func _point(song: int, score: int, index: int) -> Vector2:
|
||||
# Lines of players with the same score sit side by side instead of on top of each other
|
||||
var spread: float = get_theme_constant(&"line_width", THEME_TYPE) \
|
||||
* (index - (_history.players.size() - 1) / 2.0)
|
||||
return Vector2(_x(song), _y(score) + spread)
|
||||
|
||||
|
||||
func _x(song: int) -> float:
|
||||
var width: float = size.x - MARGIN_LEFT - MARGIN_RIGHT
|
||||
return MARGIN_LEFT + song * width / maxi(_history.songs.size(), 1)
|
||||
|
||||
|
||||
func _y(score: int) -> float:
|
||||
var height: float = size.y - MARGIN_TOP - MARGIN_BOTTOM
|
||||
return size.y - MARGIN_BOTTOM - score * height / _top_score()
|
||||
|
||||
|
||||
func _top_score() -> int:
|
||||
var top: int = maxi(_winning_score, 1)
|
||||
for player: PlayerObject in _history.players:
|
||||
top = maxi(top, _history.final_score(player.id))
|
||||
return top
|
||||
@@ -0,0 +1 @@
|
||||
uid://ccxvqduq1wmhp
|
||||
+155
-91
@@ -1,29 +1,34 @@
|
||||
class_name SearchPopup
|
||||
extends BasePopup
|
||||
## Searches every soundtrack on the server as you type, with [SoundtrackSearch].
|
||||
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
|
||||
## highlights the letters that matched. Soundtracks are also found by their alternative
|
||||
## names; the alternative name that matched is shown after the real name.
|
||||
|
||||
## Emitted with every soundtrack on the server when the list has loaded.
|
||||
signal soundtracks_loaded(list: Array[Soundtrack])
|
||||
|
||||
## Every soundtrack on the server, sorted by name.
|
||||
var soundtracks: Array = []
|
||||
var label_pool: Array[Label] = []
|
||||
|
||||
var _matcher: SoundtrackSearch = SoundtrackSearch.new()
|
||||
# One row per soundtrack, in the order of soundtracks. Rows are reused between searches.
|
||||
var _rows: Array[PanelContainer] = []
|
||||
var _index_of: Dictionary = {}
|
||||
# The other names of each soundtrack, by its real name
|
||||
var _alternatives: Dictionary = {}
|
||||
|
||||
@onready var search_list: VBoxContainer = %SearchResults
|
||||
@onready var search_panel: Panel = %SearchPanel
|
||||
@onready var clear_button: Button = $ClearButton
|
||||
@onready var search_bar: TextEdit = $Searchbar
|
||||
@onready var search_field: LineEdit = %SearchField
|
||||
@onready var clear_button: Button = %ClearButton
|
||||
@onready var count_label: Label = %CountLabel
|
||||
@onready var empty_label: Label = %EmptyLabel
|
||||
@onready var search_results: VBoxContainer = %SearchResults
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
clear_button.pressed.connect(clear)
|
||||
search_bar.grab_focus()
|
||||
search_bar.text_changed.connect(search)
|
||||
visibility_changed.connect(focus)
|
||||
|
||||
|
||||
func focus() -> void:
|
||||
if visible:
|
||||
search_bar.grab_focus()
|
||||
clear()
|
||||
search_field.text_changed.connect(func(_text: String) -> void: search())
|
||||
visibility_changed.connect(_on_visibility_changed)
|
||||
|
||||
|
||||
func close() -> void:
|
||||
@@ -31,94 +36,153 @@ func close() -> void:
|
||||
super()
|
||||
|
||||
|
||||
## Empties the search field and shows every soundtrack.
|
||||
func clear() -> void:
|
||||
search_field.text = ""
|
||||
search()
|
||||
search_field.grab_focus()
|
||||
|
||||
|
||||
## Shows the soundtracks matching the search field, best matches first, or all of them
|
||||
## when it is empty.
|
||||
func search() -> void:
|
||||
GameLog.debug(search_bar.text)
|
||||
var search_text: String = search_bar.text
|
||||
|
||||
if search_text == "":
|
||||
show_all_soundtracks()
|
||||
return
|
||||
|
||||
perform_search(search_text)
|
||||
|
||||
|
||||
func show_all_soundtracks() -> void:
|
||||
# Hide all visible labels and add to pool
|
||||
for child: Label in search_list.get_children():
|
||||
if child.visible:
|
||||
child.visible = false
|
||||
label_pool.append(child)
|
||||
|
||||
# Show all soundtracks in order
|
||||
for soundtrack: String in soundtracks:
|
||||
add_soundtrack_reuse(soundtrack)
|
||||
|
||||
|
||||
func perform_search(search_text: String) -> void:
|
||||
# Hide all visible labels and add to pool
|
||||
for child: Label in search_list.get_children():
|
||||
if child.visible:
|
||||
child.visible = false
|
||||
label_pool.append(child)
|
||||
|
||||
for soundtrack: String in _matcher.find_matches(search_text):
|
||||
add_soundtrack_reuse(soundtrack)
|
||||
|
||||
|
||||
func add_soundtrack(soundtrack: String) -> void:
|
||||
# Backward compatibility - delegates to reuse version
|
||||
add_soundtrack_reuse(soundtrack)
|
||||
|
||||
|
||||
## Shows [param soundtrack], reusing a hidden label from the pool when there is one.
|
||||
func add_soundtrack_reuse(soundtrack: String) -> void:
|
||||
var label: Label
|
||||
if label_pool.size() > 0:
|
||||
label = label_pool.pop_front()
|
||||
label.text = soundtrack
|
||||
label.visible = true
|
||||
# Move to end to maintain display order
|
||||
search_list.move_child(label, search_list.get_child_count() - 1)
|
||||
var search_text: String = search_field.text.strip_edges()
|
||||
var shown: Array[int] = []
|
||||
if search_text.is_empty():
|
||||
shown.assign(range(soundtracks.size()))
|
||||
else:
|
||||
label = Label.new()
|
||||
label.text = soundtrack
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
search_list.add_child(label)
|
||||
label.visible = true
|
||||
for soundtrack: String in _matcher.find_matches(search_text):
|
||||
shown.append(_index_of[soundtrack])
|
||||
|
||||
var is_shown: Array[bool] = []
|
||||
is_shown.resize(_rows.size())
|
||||
for position: int in shown.size():
|
||||
var row: PanelContainer = _rows[shown[position]]
|
||||
if row.get_index() != position:
|
||||
search_results.move_child(row, position)
|
||||
# Rows that come back with the search fade in
|
||||
if !row.visible:
|
||||
Anim.fade_in(row, Anim.FAST)
|
||||
row.visible = true
|
||||
row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven"
|
||||
_show_name(row, soundtracks[shown[position]], search_text)
|
||||
is_shown[shown[position]] = true
|
||||
for index: int in _rows.size():
|
||||
if !is_shown[index]:
|
||||
_rows[index].visible = false
|
||||
|
||||
empty_label.visible = shown.is_empty() && !soundtracks.is_empty()
|
||||
if search_text.is_empty():
|
||||
count_label.text = "%d soundtracks" % soundtracks.size()
|
||||
else:
|
||||
count_label.text = "%d of %d soundtracks" % [shown.size(), soundtracks.size()]
|
||||
|
||||
|
||||
func check_if_soundtrack_exists(soundtrack: String) -> bool:
|
||||
var soundtrack_exists: bool = false
|
||||
for child: Label in search_list.get_children():
|
||||
if child.text == soundtrack and child.visible:
|
||||
soundtrack_exists = true
|
||||
return soundtrack_exists
|
||||
## The names of the soundtracks shown, in the order they are shown.
|
||||
func shown_names() -> Array[String]:
|
||||
var names: Array[String] = []
|
||||
for row: PanelContainer in search_results.get_children():
|
||||
if row.visible:
|
||||
names.append(row.get_meta(&"soundtrack"))
|
||||
return names
|
||||
|
||||
|
||||
func get_list_of_soundtracks() -> void:
|
||||
GameLog.debug("get_list_of_soundtracks")
|
||||
var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void:
|
||||
if typeof(array) == TYPE_ARRAY:
|
||||
# Reuse existing labels: hide all and add to pool
|
||||
for child: Label in search_list.get_children():
|
||||
child.visible = false
|
||||
label_pool.append(child)
|
||||
set_soundtracks(array)
|
||||
for soundtrack: String in soundtracks:
|
||||
add_soundtrack_reuse(soundtrack)
|
||||
else:
|
||||
GameLog.warning("Unexpected data")
|
||||
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
|
||||
if Settings.is_offline():
|
||||
set_soundtracks(Settings.music_folder.soundtrack_names())
|
||||
return
|
||||
var with_names: Callable = func with_names(list: Array[Soundtrack]) -> void:
|
||||
var names: Array[String] = []
|
||||
var alternatives: Dictionary = {}
|
||||
for soundtrack: Soundtrack in list:
|
||||
names.append(soundtrack.name)
|
||||
alternatives[soundtrack.name] = soundtrack.alternative_names
|
||||
names.sort_custom(func(a: String, b: String) -> bool:
|
||||
return a.naturalnocasecmp_to(b) < 0)
|
||||
set_soundtracks(names, alternatives)
|
||||
soundtracks_loaded.emit(list)
|
||||
MusicServerClient.fetch_soundtracks(with_names, _load_names_only)
|
||||
|
||||
|
||||
## Replaces the searchable soundtracks.
|
||||
func set_soundtracks(list: Array) -> void:
|
||||
## Replaces the searchable soundtracks and shows them, keeping the current search.
|
||||
## [param alternatives] has the other names of a soundtrack, by its real name.
|
||||
func set_soundtracks(list: Array, alternatives: Dictionary = {}) -> void:
|
||||
soundtracks = []
|
||||
soundtracks.append_array(list)
|
||||
_matcher.set_names(soundtracks)
|
||||
_alternatives = alternatives
|
||||
_matcher.set_names(soundtracks, alternatives)
|
||||
_index_of.clear()
|
||||
for index: int in range(soundtracks.size() - 1, -1, -1):
|
||||
_index_of[soundtracks[index]] = index
|
||||
while _rows.size() < soundtracks.size():
|
||||
var row: PanelContainer = _make_row()
|
||||
search_results.add_child(row)
|
||||
_rows.append(row)
|
||||
while _rows.size() > soundtracks.size():
|
||||
_rows.pop_back().free()
|
||||
for index: int in soundtracks.size():
|
||||
_rows[index].set_meta(&"soundtrack", soundtracks[index])
|
||||
search()
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
search_bar.text = ""
|
||||
show_all_soundtracks()
|
||||
search_bar.grab_focus()
|
||||
## [param soundtrack_name] as BBCode, with the letters [param search_text] matched in
|
||||
## [param highlight_color].
|
||||
static func highlighted_name(
|
||||
soundtrack_name: String, search_text: String, highlight_color: Color) -> String:
|
||||
var matched: Array[int] = SoundtrackSearch.matched_letters(soundtrack_name, search_text)
|
||||
var text: String = ""
|
||||
var in_highlight: bool = false
|
||||
for i: int in soundtrack_name.length():
|
||||
var highlight: bool = matched.has(i)
|
||||
if highlight != in_highlight:
|
||||
text += "[color=#%s]" % highlight_color.to_html(false) if highlight else "[/color]"
|
||||
in_highlight = highlight
|
||||
var letter: String = soundtrack_name[i]
|
||||
text += "[lb]" if letter == "[" else letter
|
||||
if in_highlight:
|
||||
text += "[/color]"
|
||||
return text
|
||||
|
||||
|
||||
func _make_row() -> PanelContainer:
|
||||
var row: PanelContainer = PanelContainer.new()
|
||||
var label: RichTextLabel = RichTextLabel.new()
|
||||
label.theme_type_variation = &"SearchResult"
|
||||
label.bbcode_enabled = true
|
||||
label.fit_content = true
|
||||
label.scroll_active = false
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.add_child(label)
|
||||
return row
|
||||
|
||||
|
||||
# Servers without /soundtracks only list the names
|
||||
func _load_names_only() -> void:
|
||||
MusicServerClient.fetch_all_soundtracks_ordered(func(array: Variant) -> void:
|
||||
if typeof(array) == TYPE_ARRAY:
|
||||
set_soundtracks(array)
|
||||
else:
|
||||
GameLog.warning("Unexpected data"))
|
||||
|
||||
|
||||
func _show_name(row: PanelContainer, soundtrack_name: String, search_text: String) -> void:
|
||||
var highlight: Color = get_theme_color(&"font_color", &"HighlightLarge")
|
||||
var text: String = highlighted_name(soundtrack_name, search_text, highlight)
|
||||
# Found by another name: show which, dimmer, after the real name
|
||||
var alternative: String = _matcher.matched_alternative(soundtrack_name) \
|
||||
if !search_text.is_empty() else ""
|
||||
if !alternative.is_empty():
|
||||
text += " [color=#%s]%s[/color]" % [
|
||||
get_theme_color(&"default_color", &"SongStatsSecondary").to_html(false),
|
||||
highlighted_name(alternative, search_text, highlight)]
|
||||
var label: RichTextLabel = row.get_child(0)
|
||||
# Setting the same text again would make the label lay it out again for nothing
|
||||
if label.text != text:
|
||||
label.text = text
|
||||
|
||||
|
||||
func _on_visibility_changed() -> void:
|
||||
if visible:
|
||||
clear()
|
||||
+225
-142
@@ -1,154 +1,202 @@
|
||||
class_name SettingsPopup
|
||||
extends BasePopup
|
||||
## The settings: how songs are shown and played, the winning score, the song cache,
|
||||
## which MusicServer to use and fullscreen.
|
||||
## The application settings: which MusicServer or music folder to play from, the theme,
|
||||
## fullscreen, fewer animations, how many songs to download ahead, how random songs are
|
||||
## picked and whether plays count in the statistics, and a button that opens the folder
|
||||
## the settings are kept in. Opened from the Settings button in the top bar.
|
||||
##
|
||||
## A music folder (see [MusicFolder]) is used instead of the server until Use server is
|
||||
## pressed or the game is closed. The settings that need the server are off meanwhile.
|
||||
## A folder of characters (see [FolderCharacters]) is used the same way; without one, a
|
||||
## music folder's own [code]characters[/code] folder is used, or else the server's.
|
||||
##
|
||||
## The quiz settings, such as the winning score, are in [QuizSettingsCard].
|
||||
|
||||
## Emitted when another MusicServer is picked.
|
||||
## Emitted when another MusicServer or music folder is picked, or a prepared quiz's play
|
||||
## order is changed. A new game starts.
|
||||
signal server_changed
|
||||
|
||||
## Emitted when a folder of characters is picked or Use server is pressed for them.
|
||||
signal characters_changed
|
||||
## Emitted when Clear cache is pressed, for the caches to delete their files.
|
||||
signal clear_cache_requested
|
||||
|
||||
## Space between the rows and the panel's edges.
|
||||
const PADDING: Vector2 = Vector2(20, 12)
|
||||
|
||||
@onready var settings_panel: Panel = %SettingsPanel
|
||||
@onready var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton
|
||||
@onready var hide_next_track_button: CheckButton = %HideNextTrackCheckButton
|
||||
@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
|
||||
@onready var low_played_button: CheckButton = %LowPlayedCheckButton
|
||||
@onready var hide_beginning_button: CheckButton = %HideBeginningCheckButton
|
||||
@onready var hide_length_button: CheckButton = %HideLengthCheckButton
|
||||
@onready var hide_ticks_button: CheckButton = %HideTicksCheckButton
|
||||
@onready var score_label: Label = %ScoreLabel
|
||||
@onready var lower_winning_score_button: Button = %LowerButton
|
||||
@onready var increase_winning_score_button: Button = %IncreaseButton
|
||||
@onready var select_server_button: OptionButton = %SelectServerButton
|
||||
@onready var fullscreen_button: CheckButton = %FullscreenButton
|
||||
@onready var reduce_animations_button: CheckButton = %ReduceAnimationsButton
|
||||
@onready var theme_button: OptionButton = %ThemeButton
|
||||
@onready var cache_label: Label = %CacheLabel
|
||||
@onready var lower_cache_button: Button = %LowerCacheButton
|
||||
@onready var increase_cache_button: Button = %IncreaseCacheButton
|
||||
@onready var inspiration_speed_label: Label = %InspirationListSpeedLabel
|
||||
@onready var lower_inspiration_speed_button: Button = %LowerInspirationSpeedButton
|
||||
@onready var increase_inspiration_speed_button: Button = %IncreaseInspirationSpeedButton
|
||||
@onready var hide_for_seconds_label: Label = %HideForSecondsLabel
|
||||
@onready var lower_hide_for_seconds_button: Button = %LowerHideForSecondsButton
|
||||
@onready var increase_hide_for_seconds_button: Button = %IncreaseHideForSecondsButton
|
||||
@onready var select_server_button: OptionButton = %SelectServerButton
|
||||
@onready var fullscreen_button: CheckButton = %FullscreenButton
|
||||
@onready var local_button: CheckButton = %LocalButton
|
||||
@onready var random_mode_button: OptionButton = %RandomModeButton
|
||||
@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
|
||||
@onready var rows: VBoxContainer = %VBoxContainer
|
||||
@onready var folder_label: Label = %FolderLabel
|
||||
@onready var choose_folder_button: Button = %ChooseFolderButton
|
||||
@onready var use_server_button: Button = %UseServerButton
|
||||
@onready var folder_error_label: Label = %FolderErrorLabel
|
||||
@onready var play_order_row: HBoxContainer = %PlayOrderRow
|
||||
@onready var play_order_button: OptionButton = %PlayOrderButton
|
||||
@onready var folder_dialog: FileDialog = %FolderDialog
|
||||
@onready var characters_label: Label = %CharactersLabel
|
||||
@onready var choose_characters_button: Button = %ChooseCharactersButton
|
||||
@onready var use_server_characters_button: Button = %UseServerCharactersButton
|
||||
@onready var characters_error_label: Label = %CharactersErrorLabel
|
||||
@onready var character_folder_dialog: FileDialog = %CharacterFolderDialog
|
||||
@onready var open_config_folder_button: Button = %OpenConfigFolderButton
|
||||
@onready var cache_size_label: Label = %CacheSizeLabel
|
||||
@onready var clear_cache_button: Button = %ClearCacheButton
|
||||
|
||||
## Shows a folder in the system's file manager. The tests replace it, so no file manager
|
||||
## opens while they run.
|
||||
var show_folder: Callable = _show_in_file_manager
|
||||
|
||||
# Where the panel's middle is, so it stays centred when rows are shown or hidden
|
||||
var _panel_middle_y: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for server: String in Settings.servers:
|
||||
select_server_button.add_item(server)
|
||||
for entry: Dictionary in Settings.THEMES:
|
||||
theme_button.add_item(entry.name)
|
||||
theme_button.select(Settings.THEMES.find(Settings.theme_entry(Settings.theme_id)))
|
||||
theme_button.item_selected.connect(select_theme)
|
||||
fullscreen_button.button_pressed = Settings.fullscreen
|
||||
select_server_button.selected = Settings.selected_server
|
||||
select_server_button.select(Settings.selected_server)
|
||||
cache_label.text = str(Playlist.number_of_tracks_to_preload)
|
||||
|
||||
stop_after_current_button.pressed.connect(stop_after_current)
|
||||
hide_next_track_button.pressed.connect(hide_next_track)
|
||||
add_to_database_button.pressed.connect(add_to_database)
|
||||
low_played_button.pressed.connect(low_played)
|
||||
hide_beginning_button.pressed.connect(hide_beginning)
|
||||
hide_length_button.pressed.connect(hide_length)
|
||||
hide_ticks_button.pressed.connect(hide_ticks)
|
||||
lower_winning_score_button.pressed.connect(lower_winning_score)
|
||||
increase_winning_score_button.pressed.connect(increase_winning_score)
|
||||
fullscreen_button.pressed.connect(fullscreen)
|
||||
reduce_animations_button.button_pressed = Settings.reduce_animations
|
||||
reduce_animations_button.toggled.connect(set_reduce_animations)
|
||||
choose_folder_button.pressed.connect(folder_dialog.popup_centered)
|
||||
folder_dialog.dir_selected.connect(use_music_folder)
|
||||
use_server_button.pressed.connect(use_server)
|
||||
choose_characters_button.pressed.connect(character_folder_dialog.popup_centered)
|
||||
character_folder_dialog.dir_selected.connect(use_character_folder)
|
||||
use_server_characters_button.pressed.connect(use_server_characters)
|
||||
open_config_folder_button.pressed.connect(open_config_folder)
|
||||
open_config_folder_button.tooltip_text = Settings.config_dir()
|
||||
clear_cache_button.pressed.connect(clear_cache)
|
||||
play_order_button.item_selected.connect(_on_play_order_selected)
|
||||
lower_cache_button.pressed.connect(lower_cache)
|
||||
increase_cache_button.pressed.connect(increase_cache)
|
||||
lower_inspiration_speed_button.pressed.connect(lower_inspiration_speed)
|
||||
increase_inspiration_speed_button.pressed.connect(increase_inspiration_speed)
|
||||
lower_hide_for_seconds_button.pressed.connect(lower_hide_for_seconds)
|
||||
increase_hide_for_seconds_button.pressed.connect(increase_hide_for_seconds)
|
||||
fullscreen_button.pressed.connect(fullscreen)
|
||||
local_button.pressed.connect(local_play)
|
||||
|
||||
stop_after_current_button.button_pressed = Settings.stop_after_current
|
||||
hide_next_track_button.button_pressed = Settings.hide_next_track
|
||||
add_to_database_button.button_pressed = Settings.add_to_stats
|
||||
low_played_button.button_pressed = Settings.use_low_played_mode
|
||||
hide_beginning_button.button_pressed = Settings.hide_beginning
|
||||
hide_length_button.button_pressed = Settings.hide_length
|
||||
hide_ticks_button.button_pressed = Settings.hide_ticks
|
||||
score_label.text = str(Settings.winning_score)
|
||||
cache_label.text = str(Playlist.number_of_tracks_to_preload)
|
||||
inspiration_speed_label.text = str(Settings.inspiration_list_speed)
|
||||
hide_for_seconds_label.text = str(Settings.hide_for_seconds)
|
||||
fullscreen_button.button_pressed = Settings.fullscreen
|
||||
update_hide_for_seconds_enabled()
|
||||
select_server_button.select(Settings.selected_server)
|
||||
select_server_button.item_selected.connect(select_server)
|
||||
random_mode_button.item_selected.connect(
|
||||
func(mode: int) -> void: Settings.random_mode = mode as Settings.RandomMode)
|
||||
add_to_database_button.toggled.connect(func(on: bool) -> void: Settings.add_to_stats = on)
|
||||
visibility_changed.connect(_on_visibility_changed)
|
||||
# Alt+Enter switches fullscreen from the main window, which resizes the window
|
||||
get_window().size_changed.connect(_update_fullscreen_button)
|
||||
_panel_middle_y = settings_panel.position.y + settings_panel.size.y / 2
|
||||
_show_music_source()
|
||||
|
||||
|
||||
# Settings can change outside this popup, e.g. winning turns statistics off
|
||||
func _on_visibility_changed() -> void:
|
||||
if visible:
|
||||
add_to_database_button.button_pressed = Settings.add_to_stats
|
||||
_update_fullscreen_button()
|
||||
## Switches to the theme at [param index] in [constant Settings.THEMES] at once.
|
||||
func select_theme(index: int) -> void:
|
||||
Settings.apply_theme(Settings.THEMES[index].id)
|
||||
|
||||
|
||||
func _update_fullscreen_button() -> void:
|
||||
fullscreen_button.set_pressed_no_signal(Settings.fullscreen)
|
||||
## Turns every animation off or on, and saves it.
|
||||
func set_reduce_animations(on: bool) -> void:
|
||||
Settings.reduce_animations = on
|
||||
Settings.save_settings()
|
||||
|
||||
|
||||
func fullscreen() -> void:
|
||||
Settings.fullscreen = !Settings.fullscreen
|
||||
if Settings.fullscreen:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
|
||||
if !Settings.fullscreen:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
|
||||
Settings.set_fullscreen(!Settings.fullscreen)
|
||||
|
||||
|
||||
func local_play() -> void:
|
||||
Settings.play_local = !Settings.play_local
|
||||
## Plays songs from the folder at [param path] instead of the server's. A folder with
|
||||
## nothing to play is not used, and the popup says so.
|
||||
func use_music_folder(path: String) -> void:
|
||||
var folder: MusicFolder = MusicFolder.scan(path)
|
||||
if folder.kind == MusicFolder.Kind.EMPTY:
|
||||
folder_error_label.text = "No songs found in " + folder.display_name()
|
||||
folder_error_label.visible = true
|
||||
_fit_to_rows()
|
||||
return
|
||||
folder.set_play_order(play_order_button.selected as MusicFolder.PlayOrder)
|
||||
Settings.music_folder = folder
|
||||
_show_music_source()
|
||||
server_changed.emit()
|
||||
|
||||
|
||||
func stop_after_current() -> void:
|
||||
Settings.stop_after_current = !Settings.stop_after_current
|
||||
## Plays from the server again instead of the music folder.
|
||||
func use_server() -> void:
|
||||
Settings.music_folder = null
|
||||
_show_music_source()
|
||||
server_changed.emit()
|
||||
|
||||
|
||||
func hide_next_track() -> void:
|
||||
Settings.hide_next_track = !Settings.hide_next_track
|
||||
## Shows the folder with the config file, the saved settings and the cached characters
|
||||
## and songs in the system's file manager.
|
||||
func open_config_folder() -> void:
|
||||
var dir: String = Settings.config_dir()
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
show_folder.call(dir)
|
||||
|
||||
|
||||
func add_to_database() -> void:
|
||||
Settings.add_to_stats = !Settings.add_to_stats
|
||||
## Deletes the downloaded character pictures, covers and songs, except the songs the
|
||||
## quiz uses, and shows how much space that freed.
|
||||
func clear_cache() -> void:
|
||||
var freed: int = cache_size()
|
||||
clear_cache_requested.emit()
|
||||
Playlist.clear_song_files(Playlist.song_files_in_use())
|
||||
cache_size_label.text = "Cleared " + size_text(freed)
|
||||
clear_cache_button.disabled = true
|
||||
|
||||
|
||||
func low_played() -> void:
|
||||
Settings.use_low_played_mode = !Settings.use_low_played_mode
|
||||
## Shows how much space the downloaded files take.
|
||||
func show_cache_size() -> void:
|
||||
var size: int = cache_size()
|
||||
cache_size_label.text = size_text(size)
|
||||
clear_cache_button.disabled = size == 0
|
||||
|
||||
|
||||
func hide_beginning() -> void:
|
||||
Settings.hide_beginning = !Settings.hide_beginning
|
||||
update_hide_for_seconds_enabled()
|
||||
## How many bytes the downloaded character pictures, covers and songs take, leaving out
|
||||
## the songs the quiz uses, which clearing keeps.
|
||||
static func cache_size() -> int:
|
||||
return FileCache.folder_size(CharacterCache.folder()) \
|
||||
+ FileCache.folder_size(CoverCache.folder()) \
|
||||
+ FileCache.folder_size(Playlist.songs_folder(), Playlist.song_files_in_use())
|
||||
|
||||
|
||||
func update_hide_for_seconds_enabled() -> void:
|
||||
var enabled: bool = Settings.hide_beginning
|
||||
%HideForSecondsHBoxContainer.visible = enabled
|
||||
## [param bytes] for people, e.g. "350 KB" or "14.2 MB".
|
||||
static func size_text(bytes: int) -> String:
|
||||
if bytes == 0:
|
||||
return "Nothing"
|
||||
if bytes < 1 << 20:
|
||||
return "%d KB" % ceili(bytes / 1024.0)
|
||||
return "%.1f MB" % (bytes / float(1 << 20))
|
||||
|
||||
|
||||
func hide_length() -> void:
|
||||
Settings.hide_length = !Settings.hide_length
|
||||
## Reads the characters from the folder at [param path] instead of the server. A folder
|
||||
## without pictures is not used, and the popup says so.
|
||||
func use_character_folder(path: String) -> void:
|
||||
if FolderCharacters.picture_names(path).is_empty():
|
||||
characters_error_label.text = "No pictures found in " + path.trim_suffix("/").get_file()
|
||||
characters_error_label.visible = true
|
||||
_fit_to_rows()
|
||||
return
|
||||
Settings.character_folder = path.trim_suffix("/")
|
||||
_show_music_source()
|
||||
characters_changed.emit()
|
||||
|
||||
|
||||
func hide_ticks() -> void:
|
||||
Settings.hide_ticks = !Settings.hide_ticks
|
||||
|
||||
|
||||
func lower_winning_score() -> void:
|
||||
Settings.winning_score = max(Settings.winning_score - 1, 1)
|
||||
score_label.text = str(Settings.winning_score)
|
||||
|
||||
|
||||
func increase_winning_score() -> void:
|
||||
Settings.winning_score += 1
|
||||
score_label.text = str(Settings.winning_score)
|
||||
|
||||
|
||||
func set_winning_score(new_score: int) -> void:
|
||||
Settings.winning_score = new_score
|
||||
score_label.text = str(Settings.winning_score)
|
||||
## Uses the server's characters again, or the music folder's own if it has them.
|
||||
func use_server_characters() -> void:
|
||||
Settings.character_folder = ""
|
||||
_show_music_source()
|
||||
characters_changed.emit()
|
||||
|
||||
|
||||
func lower_cache() -> void:
|
||||
Playlist.number_of_tracks_to_preload -= 1
|
||||
if Playlist.number_of_tracks_to_preload < 1:
|
||||
Playlist.number_of_tracks_to_preload = 1
|
||||
Playlist.number_of_tracks_to_preload = maxi(Playlist.number_of_tracks_to_preload - 1, 1)
|
||||
cache_label.text = str(Playlist.number_of_tracks_to_preload)
|
||||
|
||||
|
||||
@@ -157,52 +205,87 @@ func increase_cache() -> void:
|
||||
cache_label.text = str(Playlist.number_of_tracks_to_preload)
|
||||
|
||||
|
||||
func lower_inspiration_speed() -> void:
|
||||
Settings.inspiration_list_speed -= 1
|
||||
if Settings.inspiration_list_speed < 1:
|
||||
Settings.inspiration_list_speed = 1
|
||||
inspiration_speed_label.text = str(Settings.inspiration_list_speed)
|
||||
|
||||
|
||||
func increase_inspiration_speed() -> void:
|
||||
Settings.inspiration_list_speed += 1
|
||||
inspiration_speed_label.text = str(Settings.inspiration_list_speed )
|
||||
|
||||
|
||||
func lower_hide_for_seconds() -> void:
|
||||
Settings.hide_for_seconds -= 1.0
|
||||
if Settings.hide_for_seconds < 0:
|
||||
Settings.hide_for_seconds = 0
|
||||
hide_for_seconds_label.text = str(Settings.hide_for_seconds)
|
||||
|
||||
|
||||
func increase_hide_for_seconds() -> void:
|
||||
Settings.hide_for_seconds += 1.0
|
||||
if Settings.hide_for_seconds > 10.0:
|
||||
Settings.hide_for_seconds = 10.0
|
||||
hide_for_seconds_label.text = str(Settings.hide_for_seconds)
|
||||
|
||||
|
||||
func select_server(new_server: int) -> void:
|
||||
GameLog.debug("select_server")
|
||||
Settings.default_path = select_server_button.get_item_text(new_server)
|
||||
Settings.selected_server = new_server
|
||||
GameLog.debug("Settings.default_path: " + Settings.default_path)
|
||||
GameLog.debug("Settings.selected_server: " + str(Settings.selected_server))
|
||||
GameLog.debug("new_server: " + str(new_server))
|
||||
GameLog.debug("select_server_button.get_item_text(new_server): ",
|
||||
select_server_button.get_item_text(new_server))
|
||||
server_changed.emit()
|
||||
_use_server(new_server)
|
||||
|
||||
|
||||
## Picks [param new_server] in the list and uses it.
|
||||
func set_selected_server(new_server: int) -> void:
|
||||
GameLog.debug("set_selected_server")
|
||||
select_server_button.select(new_server)
|
||||
Settings.default_path = select_server_button.get_item_text(new_server)
|
||||
_use_server(new_server)
|
||||
|
||||
|
||||
func _use_server(new_server: int) -> void:
|
||||
Settings.default_path = Settings.servers[new_server]
|
||||
Settings.selected_server = new_server
|
||||
GameLog.debug("Settings.default_path: " + Settings.default_path)
|
||||
GameLog.debug("Settings.selected_server: " + str(Settings.selected_server))
|
||||
GameLog.debug("new_server: " + str(new_server))
|
||||
GameLog.debug("select_server_button.get_item_text(new_server): ",
|
||||
select_server_button.get_item_text(new_server))
|
||||
server_changed.emit()
|
||||
|
||||
|
||||
# Winning a game turns statistics off, so the toggles are refreshed every time this opens
|
||||
func _on_visibility_changed() -> void:
|
||||
if visible:
|
||||
_update_fullscreen_button()
|
||||
show_cache_size()
|
||||
random_mode_button.select(Settings.random_mode)
|
||||
add_to_database_button.set_pressed_no_signal(Settings.add_to_stats)
|
||||
else:
|
||||
folder_error_label.visible = false
|
||||
characters_error_label.visible = false
|
||||
_fit_to_rows()
|
||||
|
||||
|
||||
func _on_play_order_selected(order: int) -> void:
|
||||
if Settings.is_offline():
|
||||
Settings.music_folder.set_play_order(order as MusicFolder.PlayOrder)
|
||||
server_changed.emit()
|
||||
|
||||
|
||||
# Shows where songs come from, and turns off what needs the server while offline
|
||||
func _show_music_source() -> void:
|
||||
var folder: MusicFolder = Settings.music_folder
|
||||
folder_error_label.visible = false
|
||||
use_server_button.visible = folder != null
|
||||
if folder == null:
|
||||
folder_label.text = "None, using the server"
|
||||
folder_label.tooltip_text = ""
|
||||
else:
|
||||
folder_label.text = folder.display_name()
|
||||
folder_label.tooltip_text = folder.path
|
||||
play_order_row.visible = folder != null && folder.kind == MusicFolder.Kind.PREPARED_QUIZ
|
||||
characters_error_label.visible = false
|
||||
use_server_characters_button.visible = !Settings.character_folder.is_empty()
|
||||
characters_label.tooltip_text = Settings.characters_dir()
|
||||
if !Settings.character_folder.is_empty():
|
||||
characters_label.text = Settings.character_folder.get_file()
|
||||
elif !Settings.characters_dir().is_empty():
|
||||
characters_label.text = "From " + folder.display_name()
|
||||
else:
|
||||
characters_label.text = "From the server"
|
||||
for control: BaseButton in [select_server_button, random_mode_button, add_to_database_button]:
|
||||
control.disabled = folder != null
|
||||
_fit_to_rows()
|
||||
|
||||
|
||||
# Grows or shrinks the panel to fit the rows shown, keeping it centred
|
||||
func _fit_to_rows() -> void:
|
||||
var height: float = rows.get_combined_minimum_size().y + PADDING.y * 2
|
||||
settings_panel.size.y = height
|
||||
settings_panel.position.y = floorf(_panel_middle_y - height / 2)
|
||||
rows.position = PADDING
|
||||
rows.size = Vector2(settings_panel.size.x - PADDING.x * 2, height - PADDING.y * 2)
|
||||
|
||||
|
||||
func _show_in_file_manager(dir: String) -> void:
|
||||
var error: Error = OS.shell_show_in_file_manager(dir)
|
||||
if error != OK:
|
||||
# Not every platform has a file manager to show a folder in; open it instead
|
||||
error = OS.shell_open(dir)
|
||||
if error != OK:
|
||||
GameLog.warning("Could not open %s: %s" % [dir, error_string(error)])
|
||||
|
||||
|
||||
func _update_fullscreen_button() -> void:
|
||||
fullscreen_button.set_pressed_no_signal(Settings.fullscreen)
|
||||
+60
-46
@@ -1,65 +1,79 @@
|
||||
class_name ShortcutsPopup
|
||||
extends BasePopup
|
||||
## Lists the keyboard shortcuts. Opened with Alt+K or the Shortcuts button.
|
||||
## Lists the keyboard shortcuts, grouped by what they do. Opened with Alt+K or the
|
||||
## Shortcuts button.
|
||||
|
||||
var shortcuts_dic: Dictionary = {
|
||||
"Alt + S": "Search",
|
||||
"Alt + A": "Add Players",
|
||||
"Alt + Z": "Reset",
|
||||
"Alt + X": "Play/Pause",
|
||||
"Alt + C": "Next Song",
|
||||
"Alt + V": "Show Answer",
|
||||
"Alt + L": "Show Debug Log",
|
||||
"Alt + K": "Show shortcuts",
|
||||
"Alt + Enter": "Fullscreen",
|
||||
"Alt + UP": "Volume up",
|
||||
"Alt + DOWN": "Volume down",
|
||||
"Alt + LEFT": "Jump back in song",
|
||||
"Alt + Ctrl + LEFT": "Restart song",
|
||||
"Alt + RIGHT": "Jump forward in song",
|
||||
"Alt + 1 - 9": "Give player point",
|
||||
"Alt + Ctrl + 1 - 9": "Take point from player",
|
||||
}
|
||||
## Each group's heading and its shortcuts, as [code][keys, what they do][/code].
|
||||
## [code]tests/integration/test_shortcuts_popup.gd[/code] checks that every button
|
||||
## shortcut in the game is here with the right keys.
|
||||
const SHORTCUTS: Array = [
|
||||
["Players and points", [
|
||||
["Alt + 1–9", "Give a player a point"],
|
||||
["Alt + Ctrl + 1–9", "Take a point from a player"],
|
||||
["Alt + A", "Add players"],
|
||||
]],
|
||||
["Songs", [
|
||||
["Alt + C", "Next song"],
|
||||
["Alt + V", "Show answer"],
|
||||
["Alt + X", "Play or pause"],
|
||||
["Alt + Z", "Restart song"],
|
||||
["Alt + Ctrl + Left", "Restart song"],
|
||||
["Alt + Left", "Jump back 5 seconds"],
|
||||
["Alt + Right", "Jump forward 5 seconds"],
|
||||
["Alt + Up", "Volume up"],
|
||||
["Alt + Down", "Volume down"],
|
||||
]],
|
||||
["Windows", [
|
||||
["Alt + S", "Search"],
|
||||
["Alt + K", "Shortcuts"],
|
||||
["Alt + L", "Log"],
|
||||
["Alt + Enter", "Fullscreen"],
|
||||
]],
|
||||
]
|
||||
|
||||
## Space above each group but the first.
|
||||
const GROUP_GAP: Vector2 = Vector2(0, 8)
|
||||
|
||||
@onready var shortcuts_vbox: VBoxContainer = %shortcutsVBox
|
||||
@onready var shortcuts_panel: Panel = %ShortcutsPanel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
shortcuts_dic.sort()
|
||||
Settings.delete_children(shortcuts_vbox)
|
||||
for group: Array in SHORTCUTS:
|
||||
if shortcuts_vbox.get_child_count() > 0:
|
||||
var gap: Control = Control.new()
|
||||
gap.custom_minimum_size = GROUP_GAP
|
||||
shortcuts_vbox.add_child(gap)
|
||||
var heading: Label = Label.new()
|
||||
heading.theme_type_variation = &"HighlightLarge"
|
||||
heading.text = group[0]
|
||||
shortcuts_vbox.add_child(heading)
|
||||
var row: int = 0
|
||||
for shortcut: Array in group[1]:
|
||||
shortcuts_vbox.add_child(_make_row(shortcut[0], shortcut[1], row))
|
||||
row += 1
|
||||
|
||||
var title: Label = Label.new()
|
||||
title.theme_type_variation = &"TextLarge"
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title.text = "Shortcuts"
|
||||
shortcuts_vbox.add_child(title)
|
||||
|
||||
var count: int = 0
|
||||
for shortcut: String in shortcuts_dic.keys():
|
||||
## Every key combination in the list, e.g. "Alt + C".
|
||||
static func listed_keys() -> Array[String]:
|
||||
var keys: Array[String] = []
|
||||
for group: Array in SHORTCUTS:
|
||||
for shortcut: Array in group[1]:
|
||||
keys.append(shortcut[0])
|
||||
return keys
|
||||
|
||||
|
||||
func _make_row(keys: String, action: String, row: int) -> PanelContainer:
|
||||
var panel: PanelContainer = PanelContainer.new()
|
||||
if row % 2 == 0:
|
||||
panel.theme_type_variation = &"ShortcutRowDark"
|
||||
var hbox: HBoxContainer = HBoxContainer.new()
|
||||
hbox.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
|
||||
for text: String in [keys, action]:
|
||||
var label: Label = Label.new()
|
||||
label.theme_type_variation = &"TextLarge"
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.text = shortcut
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
label.text = text
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
var label2: Label = Label.new()
|
||||
label2.theme_type_variation = &"TextLarge"
|
||||
label2.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label2.text = shortcuts_dic[shortcut]
|
||||
label2.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
label2.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
if count % 2 == 0:
|
||||
panel.theme_type_variation = &"ShortcutRowDark"
|
||||
|
||||
hbox.add_child(label)
|
||||
hbox.add_child(label2)
|
||||
panel.add_child(hbox)
|
||||
shortcuts_vbox.add_child(panel)
|
||||
count += 1
|
||||
return panel
|
||||
@@ -1,27 +1,305 @@
|
||||
class_name SoundtrackStatsPopup
|
||||
extends BasePopup
|
||||
## Shows a soundtrack's play statistics and every song in it, most played first.
|
||||
## Opened by clicking a card in the statistics view.
|
||||
## Shows a soundtrack's cover, play statistics and alternative names, and every song in
|
||||
## it, most played first. Opened by clicking a card in the statistics view.
|
||||
##
|
||||
## Edit names opens an editor for the alternative names in place of the song list. Saving
|
||||
## sends them to the server, which search then finds the soundtrack by.
|
||||
##
|
||||
## Change cover opens the cover menu there instead: upload a picture, pick a game's or
|
||||
## anime's cover by its ID on SteamGridDB or AniList, or remove the cover so the server
|
||||
## looks for one again.
|
||||
|
||||
## Emitted when the alternative names of [param soundtrack] have been saved.
|
||||
signal alternative_names_changed(soundtrack: Soundtrack)
|
||||
## Emitted when the cover of the soundtrack [param uuid] changed, with its new cover time
|
||||
## [param modified], 0 when it was removed.
|
||||
signal cover_changed(uuid: String, modified: int)
|
||||
|
||||
## The largest picture the server takes as a cover.
|
||||
const MAX_COVER_BYTES: int = 10 << 20
|
||||
|
||||
## The covers on disk, shared with the statistics view. No cover is shown without it.
|
||||
var covers: CoverCache
|
||||
## The soundtrack shown.
|
||||
var soundtrack: SoundtrackWithSongs
|
||||
|
||||
# The names in the editor, saved only when Save is pressed
|
||||
var _edited_names: Array[String] = []
|
||||
|
||||
@onready var title_label: Label = %TitleLabel
|
||||
@onready var summary_label: Label = %SummaryLabel
|
||||
@onready var cover_picture: TextureRect = %CoverPicture
|
||||
@onready var cover_placeholder: Label = %CoverPlaceholder
|
||||
@onready var names_label: Label = %NamesLabel
|
||||
@onready var edit_names_button: Button = %EditNamesButton
|
||||
@onready var song_scroll: ScrollContainer = %ScrollContainer
|
||||
@onready var song_list: VBoxContainer = %SongList
|
||||
@onready var names_editor: VBoxContainer = %NamesEditor
|
||||
@onready var editor_names: VBoxContainer = %EditorNames
|
||||
@onready var new_name_field: LineEdit = %NewNameField
|
||||
@onready var add_name_button: Button = %AddNameButton
|
||||
@onready var editor_status: Label = %EditorStatus
|
||||
@onready var save_names_button: Button = %SaveNamesButton
|
||||
@onready var cancel_names_button: Button = %CancelNamesButton
|
||||
@onready var change_cover_button: Button = %ChangeCoverButton
|
||||
@onready var cover_editor: VBoxContainer = %CoverEditor
|
||||
@onready var choose_picture_button: Button = %ChoosePictureButton
|
||||
@onready var remove_cover_button: Button = %RemoveCoverButton
|
||||
@onready var cover_source_button: OptionButton = %CoverSourceButton
|
||||
@onready var cover_id_field: LineEdit = %CoverIdField
|
||||
@onready var use_source_button: Button = %UseSourceButton
|
||||
@onready var cover_status: Label = %CoverStatus
|
||||
@onready var close_cover_button: Button = %CloseCoverButton
|
||||
@onready var picture_dialog: FileDialog = %PictureDialog
|
||||
|
||||
|
||||
## Shows [param soundtrack] and opens the popup.
|
||||
func show_soundtrack(soundtrack: SoundtrackWithSongs) -> void:
|
||||
title_label.text = soundtrack.soundtrack_name
|
||||
var summary: PackedStringArray = [SoundtrackCard.played_text(soundtrack.soundtrack_played)]
|
||||
var last_played: String = SoundtrackCard.last_played_text(soundtrack.soundtrack_last_played)
|
||||
func _ready() -> void:
|
||||
edit_names_button.pressed.connect(edit_names)
|
||||
add_name_button.pressed.connect(add_name)
|
||||
new_name_field.text_submitted.connect(func(_text: String) -> void: add_name())
|
||||
save_names_button.pressed.connect(save_names)
|
||||
cancel_names_button.pressed.connect(stop_editing)
|
||||
change_cover_button.pressed.connect(change_cover)
|
||||
choose_picture_button.pressed.connect(picture_dialog.popup_centered)
|
||||
picture_dialog.file_selected.connect(upload_picture)
|
||||
remove_cover_button.pressed.connect(remove_cover)
|
||||
use_source_button.pressed.connect(use_cover_source)
|
||||
cover_id_field.text_submitted.connect(func(_text: String) -> void: use_cover_source())
|
||||
close_cover_button.pressed.connect(stop_editing)
|
||||
|
||||
|
||||
## Shows [param shown] and opens the popup.
|
||||
func show_soundtrack(shown: SoundtrackWithSongs) -> void:
|
||||
soundtrack = shown
|
||||
stop_editing()
|
||||
title_label.text = shown.soundtrack_name
|
||||
var summary: PackedStringArray = [SoundtrackCard.played_text(shown.soundtrack_played)]
|
||||
var last_played: String = SoundtrackCard.last_played_text(shown.soundtrack_last_played)
|
||||
if !last_played.is_empty():
|
||||
summary.append(last_played)
|
||||
summary.append("%d %s" % [soundtrack.songs.size(),
|
||||
"song" if soundtrack.songs.size() == 1 else "songs"])
|
||||
summary.append("%d %s" % [shown.songs.size(),
|
||||
"song" if shown.songs.size() == 1 else "songs"])
|
||||
summary_label.text = " · ".join(summary)
|
||||
_show_names()
|
||||
|
||||
cover_placeholder.text = SoundtrackCard.initials(shown.soundtrack_name)
|
||||
_load_cover()
|
||||
|
||||
Settings.delete_children(song_list)
|
||||
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(soundtrack.songs)
|
||||
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(shown.songs)
|
||||
for row: int in songs.size():
|
||||
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
|
||||
&"TextLarge"))
|
||||
open()
|
||||
|
||||
|
||||
## Opens the editor for the alternative names, in place of the song list.
|
||||
func edit_names() -> void:
|
||||
_edited_names.assign(soundtrack.alternative_names)
|
||||
new_name_field.text = ""
|
||||
editor_status.visible = false
|
||||
_show_edited_names()
|
||||
_show_editor(names_editor)
|
||||
new_name_field.grab_focus()
|
||||
|
||||
|
||||
## Opens the cover menu, in place of the song list.
|
||||
func change_cover() -> void:
|
||||
cover_id_field.text = ""
|
||||
cover_status.visible = false
|
||||
_set_cover_busy(false)
|
||||
_show_editor(cover_editor)
|
||||
|
||||
|
||||
## Closes the editors, without saving the names.
|
||||
func stop_editing() -> void:
|
||||
_show_editor(null)
|
||||
_set_saving(false)
|
||||
_set_cover_busy(false)
|
||||
|
||||
|
||||
## Uploads the picture at [param path] as the soundtrack's cover.
|
||||
func upload_picture(path: String) -> void:
|
||||
var picture: PackedByteArray = FileAccess.get_file_as_bytes(path)
|
||||
var content_type: String = picture_type(picture)
|
||||
if picture.size() > MAX_COVER_BYTES:
|
||||
_show_cover_status("The picture is larger than %d MB." % (MAX_COVER_BYTES >> 20))
|
||||
return
|
||||
if content_type.is_empty() || CharacterCard.image_from_bytes(picture) == null:
|
||||
_show_cover_status("Only JPEG, PNG and WebP pictures can be used.")
|
||||
return
|
||||
_set_cover_busy(true, "Uploading the picture…")
|
||||
MusicServerClient.upload_soundtrack_cover(soundtrack.soundtrack_uuid, picture,
|
||||
content_type, _on_cover_saved.bind("The new cover is saved."), _on_cover_not_saved)
|
||||
|
||||
|
||||
## Makes the cover of the game or anime with the ID in the field, on the site picked,
|
||||
## the soundtrack's cover.
|
||||
func use_cover_source() -> void:
|
||||
var id: String = cover_id_field.text.strip_edges()
|
||||
if id.is_empty() || use_source_button.disabled:
|
||||
return
|
||||
var source: String = MusicServerClient.COVER_SOURCES[cover_source_button.selected]
|
||||
_set_cover_busy(true, "Getting the cover from %s…"
|
||||
% cover_source_button.get_item_text(cover_source_button.selected))
|
||||
MusicServerClient.set_soundtrack_cover_source(soundtrack.soundtrack_uuid, source, id,
|
||||
_on_cover_saved.bind("The new cover is saved."), _on_cover_not_saved)
|
||||
|
||||
|
||||
## Removes the soundtrack's cover, so the server looks for one again.
|
||||
func remove_cover() -> void:
|
||||
_set_cover_busy(true, "Removing the cover…")
|
||||
MusicServerClient.delete_soundtrack_cover(soundtrack.soundtrack_uuid,
|
||||
_on_cover_saved.bind("The cover is removed. The server will look for a new one."),
|
||||
_on_cover_not_saved)
|
||||
|
||||
|
||||
## The type of [param picture] to upload it as, e.g. [code]"image/png"[/code], or empty
|
||||
## when it is not a JPEG, PNG or WebP picture.
|
||||
static func picture_type(picture: PackedByteArray) -> String:
|
||||
if picture.size() >= 4 && picture.slice(0, 4) == PackedByteArray([0x89, 0x50, 0x4E, 0x47]):
|
||||
return "image/png"
|
||||
if picture.size() >= 3 && picture.slice(0, 3) == PackedByteArray([0xFF, 0xD8, 0xFF]):
|
||||
return "image/jpeg"
|
||||
if picture.size() >= 12 && picture.slice(8, 12).get_string_from_ascii() == "WEBP":
|
||||
return "image/webp"
|
||||
return ""
|
||||
|
||||
|
||||
## Adds the name in the field to the edited names, unless it is empty, the soundtrack's
|
||||
## own name or already there.
|
||||
func add_name() -> void:
|
||||
var new_name: String = new_name_field.text.strip_edges()
|
||||
if new_name.is_empty() || new_name.to_lower() == soundtrack.soundtrack_name.to_lower() \
|
||||
|| _edited_names.any(func(name: String) -> bool:
|
||||
return name.to_lower() == new_name.to_lower()):
|
||||
return
|
||||
_edited_names.append(new_name)
|
||||
new_name_field.text = ""
|
||||
_show_edited_names()
|
||||
|
||||
|
||||
## Takes [param removed] out of the edited names.
|
||||
func remove_name(removed: String) -> void:
|
||||
_edited_names.erase(removed)
|
||||
_show_edited_names()
|
||||
|
||||
|
||||
## Sends the edited names to the server. The editor closes when they are saved.
|
||||
func save_names() -> void:
|
||||
# A name typed but not added yet is meant to be saved too
|
||||
add_name()
|
||||
_set_saving(true)
|
||||
MusicServerClient.set_alternative_names(soundtrack.soundtrack_uuid, _edited_names,
|
||||
_on_names_saved, _on_names_not_saved)
|
||||
|
||||
|
||||
func _on_names_saved(updated: Soundtrack) -> void:
|
||||
soundtrack.alternative_names = updated.alternative_names
|
||||
_show_names()
|
||||
stop_editing()
|
||||
alternative_names_changed.emit(updated)
|
||||
|
||||
|
||||
func _on_names_not_saved() -> void:
|
||||
_set_saving(false)
|
||||
editor_status.text = "Could not save the names. Try again."
|
||||
editor_status.visible = true
|
||||
|
||||
|
||||
func _set_saving(saving: bool) -> void:
|
||||
save_names_button.disabled = saving
|
||||
cancel_names_button.disabled = saving
|
||||
save_names_button.text = "Saving…" if saving else "Save"
|
||||
|
||||
|
||||
func _show_names() -> void:
|
||||
var names: Array[String] = soundtrack.alternative_names
|
||||
names_label.text = ", ".join(names) if !names.is_empty() else "No other names yet"
|
||||
|
||||
|
||||
func _show_edited_names() -> void:
|
||||
Settings.delete_children(editor_names)
|
||||
for row: int in _edited_names.size():
|
||||
var line: HBoxContainer = HBoxContainer.new()
|
||||
var label: Label = Label.new()
|
||||
label.text = _edited_names[row]
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
line.add_child(label)
|
||||
var remove: Button = Button.new()
|
||||
remove.text = "Remove"
|
||||
remove.focus_mode = Control.FOCUS_NONE
|
||||
remove.pressed.connect(remove_name.bind(_edited_names[row]))
|
||||
line.add_child(remove)
|
||||
var list_row: PanelContainer = PanelContainer.new()
|
||||
list_row.theme_type_variation = &"ListRowOdd" if row % 2 != 0 else &"ListRowEven"
|
||||
list_row.add_child(line)
|
||||
editor_names.add_child(list_row)
|
||||
|
||||
|
||||
# Shows [param editor] in place of the song list, or the song list when it is null
|
||||
func _show_editor(editor: Control) -> void:
|
||||
names_editor.visible = editor == names_editor
|
||||
cover_editor.visible = editor == cover_editor
|
||||
song_scroll.visible = editor == null
|
||||
edit_names_button.disabled = editor != null
|
||||
change_cover_button.disabled = editor != null
|
||||
|
||||
|
||||
# The cover changed on the server; its new time tells whether it has one and fetches it
|
||||
func _on_cover_saved(message: String) -> void:
|
||||
var uuid: String = soundtrack.soundtrack_uuid
|
||||
var with_time: Callable = func with_time(modified: int) -> void:
|
||||
if soundtrack == null || soundtrack.soundtrack_uuid != uuid:
|
||||
return
|
||||
soundtrack.cover_modified = modified
|
||||
_load_cover()
|
||||
_set_cover_busy(false)
|
||||
_show_cover_status(message)
|
||||
cover_changed.emit(uuid, modified)
|
||||
if covers != null:
|
||||
covers.refresh(uuid, with_time)
|
||||
else:
|
||||
with_time.call(soundtrack.cover_modified)
|
||||
|
||||
|
||||
func _on_cover_not_saved() -> void:
|
||||
_set_cover_busy(false)
|
||||
var reason: String = MusicServerClient.last_error
|
||||
_show_cover_status("Could not change the cover: %s."
|
||||
% (reason if !reason.is_empty() else "the server could not be reached"))
|
||||
|
||||
|
||||
func _set_cover_busy(busy: bool, message: String = "") -> void:
|
||||
for button: Button in [choose_picture_button, use_source_button, close_cover_button]:
|
||||
button.disabled = busy
|
||||
remove_cover_button.disabled = busy || soundtrack == null \
|
||||
|| soundtrack.cover_modified <= 0
|
||||
cover_source_button.disabled = busy
|
||||
if busy:
|
||||
_show_cover_status(message)
|
||||
|
||||
|
||||
func _show_cover_status(message: String) -> void:
|
||||
cover_status.text = message
|
||||
cover_status.visible = true
|
||||
|
||||
|
||||
func _load_cover() -> void:
|
||||
_show_cover(null)
|
||||
if covers != null:
|
||||
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified,
|
||||
_show_cover_of.bind(soundtrack.soundtrack_uuid))
|
||||
|
||||
|
||||
func _show_cover(cover: Image) -> void:
|
||||
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
|
||||
cover_placeholder.visible = cover == null
|
||||
|
||||
|
||||
# Covers arrive later; one for a soundtrack no longer shown is left out
|
||||
func _show_cover_of(cover: Image, uuid: String) -> void:
|
||||
if soundtrack != null && soundtrack.soundtrack_uuid == uuid:
|
||||
_show_cover(cover)
|
||||
+16
-4
@@ -17,7 +17,7 @@ var has_synced: bool = false
|
||||
@onready var no_changes_label: Label = %NoChangesLabel
|
||||
@onready var progress_hbox: HBoxContainer = %ProgressHBoxContainer
|
||||
@onready var sync_hbox: HBoxContainer = %SyncHBoxContainer
|
||||
@onready var sync_progress_bar: ProgressBar = %SyncProgressBar
|
||||
@onready var sync_progress_bar: AnimatedProgressBar = %SyncProgressBar
|
||||
@onready var soundtracks_added_vbox: VBoxContainer = %SoundtracksAddedVBox
|
||||
@onready var soundtracks_added_separator: VSeparator = %SoundtracksAddedSeparator
|
||||
@onready var soundtracks_readded_vbox: VBoxContainer = %SoundtracksReAddedVBox
|
||||
@@ -56,7 +56,7 @@ func show_section_if_has_data(data: Variant, vbox: VBoxContainer, separator: VSe
|
||||
|
||||
func start_sync() -> void:
|
||||
GameLog.debug("start_sync")
|
||||
visible = true
|
||||
open()
|
||||
if has_synced:
|
||||
# Already synced, just show the results
|
||||
show_sync_result()
|
||||
@@ -77,13 +77,24 @@ func _on_sync_timeout() -> void:
|
||||
GameLog.debug("data_received: ", data_received)
|
||||
if data_received != null:
|
||||
if data_received.has("progress"):
|
||||
sync_progress_bar.value = int(data_received.progress)
|
||||
sync_progress_bar.move_to(int(data_received.progress))
|
||||
sync_time.text = data_received.time_spent if data_received.has("time_spent") else ""
|
||||
else:
|
||||
on_sync_finished(data_received)
|
||||
_finish_progress(data_received)
|
||||
MusicServerClient.fetch_sync_progress(get_progress)
|
||||
|
||||
|
||||
# Fills the bar and lets it pop before the result takes its place
|
||||
func _finish_progress(data_received: Dictionary) -> void:
|
||||
sync_timer.stop()
|
||||
if Anim.enabled() && progress_hbox.visible:
|
||||
sync_progress_bar.move_to(sync_progress_bar.max_value)
|
||||
await get_tree().create_timer(Anim.SLOW).timeout
|
||||
Anim.pop(sync_progress_bar, 1.04, Anim.SLOW)
|
||||
await get_tree().create_timer(Anim.SLOW).timeout
|
||||
on_sync_finished(data_received)
|
||||
|
||||
|
||||
func on_sync_finished(data_received: Dictionary) -> void:
|
||||
has_synced = true
|
||||
reset_sync_result()
|
||||
@@ -218,6 +229,7 @@ func show_sync_result() -> void:
|
||||
func show_sync_progress() -> void:
|
||||
sync_title_label.text = "Sync in Progress"
|
||||
progress_hbox.visible = true
|
||||
sync_progress_bar.wait()
|
||||
no_changes_label.visible = false
|
||||
sync_hbox.visible = false
|
||||
caught_errors_vbox.visible = false
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
class_name UpdatePopup
|
||||
extends BasePopup
|
||||
## Gets the newest version of the game, opened from "Version X is available" in the top
|
||||
## bar.
|
||||
##
|
||||
## Update and restart downloads the new game, puts it in place of this one and starts it;
|
||||
## the quiz is saved first, so it continues where it was. Where the game can't update
|
||||
## itself, e.g. when it runs from the editor, only Download in browser is offered, which
|
||||
## opens the download (or, on other platforms, the release page) in the browser.
|
||||
|
||||
## Emitted just before the game quits to start the new version, so the quiz can be saved.
|
||||
signal restarting
|
||||
|
||||
## Space between the rows and the panel's edges.
|
||||
const PADDING: Vector2 = Vector2(24, 16)
|
||||
|
||||
## Opens a link in the browser. The tests replace it, so no browser opens.
|
||||
var open_url: Callable = OS.shell_open
|
||||
## The version offered.
|
||||
var latest_version: String = ""
|
||||
|
||||
var _downloading: bool = false
|
||||
# Where the panel's middle is, so it stays centred as it grows and shrinks
|
||||
var _panel_middle_y: float = 0.0
|
||||
|
||||
@onready var title_label: Label = %TitleLabel
|
||||
@onready var info_label: Label = %InfoLabel
|
||||
@onready var reason_label: Label = %ReasonLabel
|
||||
@onready var download_progress: ProgressBar = %DownloadProgress
|
||||
@onready var status_label: Label = %StatusLabel
|
||||
@onready var not_now_button: Button = %NotNowButton
|
||||
@onready var browser_button: Button = %BrowserButton
|
||||
@onready var update_now_button: Button = %UpdateNowButton
|
||||
@onready var updater: Updater = %Updater
|
||||
@onready var rows: VBoxContainer = %Rows
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
not_now_button.pressed.connect(close)
|
||||
browser_button.pressed.connect(download_in_browser)
|
||||
update_now_button.pressed.connect(update_now)
|
||||
_panel_middle_y = panel.position.y + panel.size.y / 2
|
||||
|
||||
|
||||
## Offers version [param latest] and opens the popup.
|
||||
func show_update(latest: String) -> void:
|
||||
latest_version = latest
|
||||
title_label.text = "Version %s is available" % latest
|
||||
info_label.text = "You have %s. The game restarts with the new version, and the quiz " \
|
||||
% Settings.version + "continues where it was."
|
||||
var reason: String = updater.can_update_in_place()
|
||||
update_now_button.visible = reason.is_empty()
|
||||
reason_label.visible = !reason.is_empty()
|
||||
reason_label.text = reason + " Download it in the browser instead."
|
||||
info_label.visible = reason.is_empty()
|
||||
download_progress.visible = false
|
||||
status_label.visible = false
|
||||
_set_busy(false)
|
||||
_fit_to_rows()
|
||||
open()
|
||||
|
||||
|
||||
## Stops a download that is under way when the popup is closed.
|
||||
func close() -> void:
|
||||
if _downloading:
|
||||
updater.cancel()
|
||||
_downloading = false
|
||||
_set_busy(false)
|
||||
super()
|
||||
|
||||
|
||||
## Downloads the new version, puts it in place and restarts the game.
|
||||
func update_now() -> void:
|
||||
_set_busy(true)
|
||||
_show_status("Getting the download link…")
|
||||
MusicServerClient.fetch_client_download_url(Updater.asset_for(updater.os_name, true),
|
||||
_download, _on_failed.bind("The server has no download for this version."))
|
||||
|
||||
|
||||
## Opens the new version's download in the browser, or the release page when there is
|
||||
## none for this platform.
|
||||
func download_in_browser() -> void:
|
||||
var asset: String = Updater.asset_for(updater.os_name, false)
|
||||
if asset.is_empty():
|
||||
open_url.call(Updater.RELEASES_PAGE)
|
||||
return
|
||||
_show_status("Opening the download in the browser…")
|
||||
MusicServerClient.fetch_client_download_url(asset,
|
||||
func(url: String) -> void: open_url.call(url),
|
||||
func() -> void: open_url.call(Updater.RELEASES_PAGE))
|
||||
|
||||
|
||||
func _download(url: String) -> void:
|
||||
_downloading = true
|
||||
download_progress.value = 0.0
|
||||
download_progress.visible = true
|
||||
_show_status("Downloading…")
|
||||
_fit_to_rows()
|
||||
updater.download(url, _on_progress, _install, _on_failed)
|
||||
|
||||
|
||||
func _on_progress(done: int, total: int) -> void:
|
||||
if total > 0:
|
||||
download_progress.value = float(done) / float(total)
|
||||
status_label.text = "Downloading… %s of %s" % [SettingsPopup.size_text(done),
|
||||
SettingsPopup.size_text(total)]
|
||||
|
||||
|
||||
func _install(path: String) -> void:
|
||||
_downloading = false
|
||||
_show_status("Installing…")
|
||||
var error: Error = Updater.install(path, updater.executable_path, updater.os_name)
|
||||
if error != OK:
|
||||
DirAccess.remove_absolute(path)
|
||||
_on_failed("The game could not be replaced: %s." % error_string(error))
|
||||
return
|
||||
_show_status("Restarting…")
|
||||
restarting.emit()
|
||||
updater.restart()
|
||||
|
||||
|
||||
func _on_failed(reason: String) -> void:
|
||||
_downloading = false
|
||||
download_progress.visible = false
|
||||
_set_busy(false)
|
||||
_show_status(reason)
|
||||
_fit_to_rows()
|
||||
|
||||
|
||||
func _set_busy(busy: bool) -> void:
|
||||
update_now_button.disabled = busy
|
||||
browser_button.disabled = busy
|
||||
# Closing while busy stops the download
|
||||
not_now_button.text = "Cancel" if busy else "Not now"
|
||||
|
||||
|
||||
func _show_status(message: String) -> void:
|
||||
status_label.text = message
|
||||
if !status_label.visible:
|
||||
status_label.visible = true
|
||||
_fit_to_rows()
|
||||
|
||||
|
||||
# Makes the panel as tall as its rows, around the same middle
|
||||
func _fit_to_rows() -> void:
|
||||
# A popup that was never shown hasn't laid out its rows, and wrapped text measures
|
||||
# its height from its width
|
||||
var width: float = panel.size.x - PADDING.x * 2
|
||||
for label: Label in [info_label, reason_label, status_label]:
|
||||
label.size = Vector2(width, 0)
|
||||
label.update_minimum_size()
|
||||
var height: float = rows.get_combined_minimum_size().y + PADDING.y * 2
|
||||
panel.size.y = height
|
||||
panel.position.y = floorf(_panel_middle_y - height / 2)
|
||||
rows.position = PADDING
|
||||
rows.size = Vector2(panel.size.x - PADDING.x * 2, height - PADDING.y * 2)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dupwb11ovvn7j
|
||||
+159
-26
@@ -1,11 +1,25 @@
|
||||
class_name WinnerPopup
|
||||
extends BasePopup
|
||||
## Shows who won, with their character, and where the other players placed.
|
||||
## Shows who won, with their character, where the other players placed, and how the game
|
||||
## went: a graph of everyone's score after each song and a few facts about it.
|
||||
##
|
||||
## The winners' pictures bounce in, confetti falls over the panel and the graph draws its
|
||||
## lines from left to right. See [Anim].
|
||||
|
||||
## How many pieces of confetti fly when someone wins.
|
||||
const CONFETTI_AMOUNT: int = 160
|
||||
## Size of the coloured square in front of each name in the legend.
|
||||
const LEGEND_SQUARE_SIZE: Vector2 = Vector2(14, 14)
|
||||
|
||||
@onready var winner_panel: Panel = %WinnerPanel
|
||||
@onready var winner_picture: TextureRect = %WinnerPicture
|
||||
@onready var winner_label: Label = %WinnerLabel
|
||||
@onready var players_list: VBoxContainer = %PlayersList
|
||||
@onready var legend: HBoxContainer = %Legend
|
||||
@onready var score_graph: ScoreGraph = %ScoreGraph
|
||||
@onready var streak_label: Label = %StreakLabel
|
||||
@onready var standstill_label: Label = %StandstillLabel
|
||||
@onready var shared_label: Label = %SharedLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -13,38 +27,157 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func show_winner(winner: PlayerObject) -> void:
|
||||
visible = true
|
||||
# Set winner info
|
||||
winner_label.text = winner.player_name + " won with " + str(winner.player_score) + \
|
||||
" points!!"
|
||||
winner_picture.texture = winner.character
|
||||
# The "no character" placeholder is pixel art, which blurs when scaled up smoothly
|
||||
if winner.character.resource_path == PlayerObject.NO_CHARACTER_PATH:
|
||||
winner_picture.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
else:
|
||||
winner_picture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
winner_picture.custom_minimum_size = Vector2(480, 200)
|
||||
winner_picture.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
|
||||
winner_picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
var winners: Array[PlayerObject] = [winner]
|
||||
show_winners(winners)
|
||||
|
||||
|
||||
## Shows [param winners], who share the top score, with everyone else's placing below.
|
||||
## Tied players get the same placing. With no players, it says the quiz is over.
|
||||
func show_winners(winners: Array[PlayerObject]) -> void:
|
||||
open()
|
||||
winner_label.text = winners_text(winners)
|
||||
_show_pictures(winners)
|
||||
|
||||
# Clear previous player list entries
|
||||
for child: Node in players_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
# Sort players by score (descending) and add to list
|
||||
var sorted_players: Array = Settings.player_array.duplicate()
|
||||
var sorted_players: Array[PlayerObject] = Settings.player_array.duplicate()
|
||||
sorted_players.sort_custom(
|
||||
func(a: PlayerObject, b: PlayerObject) -> bool:
|
||||
return a.player_score > b.player_score
|
||||
)
|
||||
|
||||
var placing: int = 2
|
||||
for player: PlayerObject in sorted_players:
|
||||
if player == winner:
|
||||
continue # Skip the winner, already shown above
|
||||
if player in winners:
|
||||
continue
|
||||
var label: Label = Label.new()
|
||||
label.text = str(placing) + ". " + player.player_name + " - " + \
|
||||
str(player.player_score) + " points"
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.text = "%d. %s - %s" % [placing(player), player.player_name,
|
||||
points_text(player.player_score)]
|
||||
players_list.add_child(label)
|
||||
placing += 1
|
||||
|
||||
show_history(ScoreHistory.from_playlist(Settings.player_array))
|
||||
_celebrate()
|
||||
|
||||
|
||||
## The headline for [param winners], e.g. "Crono won with 5 points!!" or "Crono and
|
||||
## Marle won with 5 points!!".
|
||||
static func winners_text(winners: Array[PlayerObject]) -> String:
|
||||
if winners.is_empty():
|
||||
return "The quiz is over!"
|
||||
var names: PackedStringArray = []
|
||||
for winner: PlayerObject in winners:
|
||||
names.append(winner.player_name)
|
||||
var joined: String = names[-1]
|
||||
if names.size() > 1:
|
||||
joined = ", ".join(names.slice(0, -1)) + " and " + names[-1]
|
||||
return "%s won with %s!!" % [joined, points_text(winners[0].player_score)]
|
||||
|
||||
|
||||
## [param points] in words, e.g. "1 point" or "5 points".
|
||||
static func points_text(points: int) -> String:
|
||||
return "%d point%s" % [points, "" if points == 1 else "s"]
|
||||
|
||||
|
||||
## Where [param player] placed: 1 plus how many players have more points, so tied
|
||||
## players share a placing.
|
||||
static func placing(player: PlayerObject) -> int:
|
||||
var place: int = 1
|
||||
for other: PlayerObject in Settings.player_array:
|
||||
if other.player_score > player.player_score:
|
||||
place += 1
|
||||
return place
|
||||
|
||||
|
||||
# The winners' pictures bounce in, confetti flies and the graph draws itself
|
||||
func _celebrate() -> void:
|
||||
for picture: Node in winner_picture.get_parent().get_children():
|
||||
if picture is TextureRect && (picture as TextureRect).visible:
|
||||
Anim.bounce_in(picture)
|
||||
score_graph.draw_lines_in()
|
||||
if Anim.enabled():
|
||||
_throw_confetti()
|
||||
|
||||
|
||||
# A burst of confetti from the top of the panel, in the players' graph colours
|
||||
func _throw_confetti() -> void:
|
||||
var old: Node = winner_panel.get_node_or_null("Confetti")
|
||||
if old != null:
|
||||
old.free()
|
||||
var confetti: CPUParticles2D = CPUParticles2D.new()
|
||||
confetti.name = "Confetti"
|
||||
confetti.position = Vector2(winner_panel.size.x / 2, 0)
|
||||
confetti.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
|
||||
confetti.emission_rect_extents = Vector2(winner_panel.size.x / 2, 4)
|
||||
confetti.amount = CONFETTI_AMOUNT
|
||||
confetti.lifetime = 2.6
|
||||
confetti.one_shot = true
|
||||
confetti.explosiveness = 0.85
|
||||
confetti.direction = Vector2.DOWN
|
||||
confetti.spread = 35.0
|
||||
confetti.gravity = Vector2(0, 260)
|
||||
confetti.initial_velocity_min = 80.0
|
||||
confetti.initial_velocity_max = 260.0
|
||||
confetti.angular_velocity_min = -360.0
|
||||
confetti.angular_velocity_max = 360.0
|
||||
confetti.scale_amount_min = 5.0
|
||||
confetti.scale_amount_max = 9.0
|
||||
var colours: Gradient = Gradient.new()
|
||||
colours.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_CONSTANT
|
||||
colours.offsets = PackedFloat32Array()
|
||||
colours.colors = PackedColorArray()
|
||||
for i: int in 7:
|
||||
colours.add_point(i / 7.0, score_graph.player_color(i))
|
||||
confetti.color_initial_ramp = colours
|
||||
confetti.finished.connect(confetti.queue_free)
|
||||
winner_panel.add_child(confetti)
|
||||
confetti.emitting = true
|
||||
|
||||
|
||||
# One picture per winner, next to each other
|
||||
func _show_pictures(winners: Array[PlayerObject]) -> void:
|
||||
var header: Node = winner_picture.get_parent()
|
||||
for extra: Node in header.get_children():
|
||||
if extra != winner_picture && extra is TextureRect:
|
||||
header.remove_child(extra)
|
||||
extra.queue_free()
|
||||
winner_picture.visible = !winners.is_empty()
|
||||
for index: int in winners.size():
|
||||
var picture: TextureRect = winner_picture
|
||||
if index > 0:
|
||||
picture = winner_picture.duplicate()
|
||||
header.add_child(picture)
|
||||
header.move_child(picture, winner_picture.get_index() + index)
|
||||
_show_character(picture, winners[index])
|
||||
|
||||
|
||||
func _show_character(picture: TextureRect, player: PlayerObject) -> void:
|
||||
picture.texture = player.character
|
||||
# The "no character" placeholder is pixel art, which blurs when scaled up smoothly
|
||||
if player.character.resource_path == PlayerObject.NO_CHARACTER_PATH:
|
||||
picture.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
else:
|
||||
picture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
|
||||
|
||||
## Shows how the game went: the score graph, its legend and the summary boxes.
|
||||
func show_history(history: ScoreHistory) -> void:
|
||||
# A prepared quiz has no winning score to draw
|
||||
var winning_score: int = 0 if Settings.is_prepared_quiz() else Settings.winning_score
|
||||
score_graph.show_history(history, winning_score)
|
||||
streak_label.text = history.longest_streak_text()
|
||||
standstill_label.text = history.longest_standstill_text()
|
||||
shared_label.text = history.most_shared_text()
|
||||
|
||||
for child: Node in legend.get_children():
|
||||
legend.remove_child(child)
|
||||
child.queue_free()
|
||||
for index: int in history.players.size():
|
||||
var player: PlayerObject = history.players[index]
|
||||
var entry: HBoxContainer = HBoxContainer.new()
|
||||
var square: ColorRect = ColorRect.new()
|
||||
square.custom_minimum_size = LEGEND_SQUARE_SIZE
|
||||
square.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
square.color = score_graph.player_color(index)
|
||||
entry.add_child(square)
|
||||
var name_label: Label = Label.new()
|
||||
name_label.text = "%s %d" % [player.player_name, history.final_score(player.id)]
|
||||
entry.add_child(name_label)
|
||||
legend.add_child(entry)
|
||||
@@ -13,6 +13,7 @@ script = ExtResource("part_script")
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=707750459]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
script = ExtResource("7_dj026")
|
||||
|
||||
[node name="MusicList" type="VBoxContainer" parent="ScrollContainer" unique_id=373398568]
|
||||
@@ -20,4 +21,4 @@ unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 10
|
||||
theme_override_constants/separation = 4
|
||||
+38
-3
@@ -2,22 +2,57 @@
|
||||
|
||||
[ext_resource type="Script" path="res://quiz/now_playing.gd" id="part_script"]
|
||||
|
||||
[node name="NowPlaying" type="VBoxContainer" unique_id=42468498]
|
||||
[node name="NowPlaying" type="HBoxContainer" unique_id=42468498]
|
||||
layout_mode = 3
|
||||
offset_right = 1088.0
|
||||
offset_bottom = 320.0
|
||||
theme_override_constants/separation = 32
|
||||
alignment = 1
|
||||
script = ExtResource("part_script")
|
||||
|
||||
[node name="SoundtrackLabel" type="Label" parent="." unique_id=173147793]
|
||||
[node name="CoverFrame" type="Panel" parent="."]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
clip_children = 2
|
||||
custom_minimum_size = Vector2(180, 270)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"SoundtrackCover"
|
||||
|
||||
[node name="CoverPicture" type="TextureRect" parent="CoverFrame"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
expand_mode = 1
|
||||
stretch_mode = 6
|
||||
|
||||
[node name="Titles" type="VBoxContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="SoundtrackLabel" type="Label" parent="Titles" unique_id=173147793]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(1, 1)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"TitleLarge"
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
text_overrun_behavior = 3
|
||||
max_lines_visible = 2
|
||||
|
||||
[node name="SongLabel" type="Label" parent="." unique_id=1687490027]
|
||||
[node name="SongLabel" type="Label" parent="Titles" unique_id=1687490027]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(1, 1)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"TitleMedium"
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
text_overrun_behavior = 3
|
||||
max_lines_visible = 2
|
||||
+47
-48
@@ -4,90 +4,89 @@
|
||||
[ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="2_hpj3s"]
|
||||
[ext_resource type="Texture2D" uid="uid://b2kj6m8qpsgb1" path="res://icons/person_remove_light.svg" id="3_j3uxe"]
|
||||
|
||||
[node name="PlayerRow" type="Control"]
|
||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
offset_bottom = 40.0
|
||||
[node name="PlayerRow" type="PanelContainer"]
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"ListRowEven"
|
||||
script = ExtResource("1_if4kc")
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="."]
|
||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Panel4" type="Panel" parent="HBoxContainer"]
|
||||
custom_minimum_size = Vector2(5, 40)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DarkSpacer"
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Name" type="Label" parent="HBoxContainer"]
|
||||
[node name="CharacterFrame" type="PanelContainer" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(130, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 1
|
||||
text = ": 0"
|
||||
vertical_alignment = 1
|
||||
clip_text = true
|
||||
text_overrun_behavior = 3
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"PlayerCharacter"
|
||||
|
||||
[node name="Panel3" type="Panel" parent="HBoxContainer"]
|
||||
custom_minimum_size = Vector2(16, 2.08165e-12)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DarkSpacer"
|
||||
|
||||
[node name="Character" type="TextureButton" parent="HBoxContainer"]
|
||||
[node name="Character" type="TextureButton" parent="HBoxContainer/CharacterFrame"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(80, 40)
|
||||
custom_minimum_size = Vector2(64, 32)
|
||||
layout_mode = 2
|
||||
tooltip_text = "Change character"
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
texture_normal = ExtResource("2_hpj3s")
|
||||
ignore_texture_size = true
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="Panel2" type="Panel" parent="HBoxContainer"]
|
||||
custom_minimum_size = Vector2(30, 40)
|
||||
[node name="Name" type="Label" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DarkSpacer"
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 1
|
||||
theme_type_variation = &"TextMedium"
|
||||
vertical_alignment = 1
|
||||
clip_text = true
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="MatchPointTag" type="Label" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Match point!"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Score" type="Label" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(40, 0)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"PlayerScore"
|
||||
text = "0"
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="AddPoint" type="Button" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(40, 40)
|
||||
custom_minimum_size = Vector2(40, 36)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "+1"
|
||||
|
||||
[node name="Panel5" type="Panel" parent="HBoxContainer"]
|
||||
custom_minimum_size = Vector2(3, 40)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DarkSpacer"
|
||||
|
||||
[node name="RemovePoint" type="Button" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(40, 40)
|
||||
custom_minimum_size = Vector2(40, 36)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "-1"
|
||||
|
||||
[node name="Panel" type="Panel" parent="HBoxContainer"]
|
||||
custom_minimum_size = Vector2(30, 40)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DarkSpacer"
|
||||
|
||||
[node name="RemovePlayer" type="TextureButton" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(40, 40)
|
||||
visible = false
|
||||
custom_minimum_size = Vector2(36, 36)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
tooltip_text = "Remove player"
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
texture_normal = ExtResource("3_j3uxe")
|
||||
stretch_mode = 0
|
||||
ignore_texture_size = true
|
||||
stretch_mode = 5
|
||||
+27
-18
@@ -2,7 +2,6 @@
|
||||
|
||||
[ext_resource type="Script" path="res://quiz/players_panel.gd" id="part_script"]
|
||||
[ext_resource type="Texture2D" uid="uid://bcfmpd7h512ef" path="res://icons/person_add_light.svg" id="5_31tjv"]
|
||||
[ext_resource type="Script" uid="uid://cgakje8gb37tf" path="res://quiz/player_name_field.gd" id="7_qsdfy"]
|
||||
|
||||
[sub_resource type="InputEventKey" id="InputEventKey_03bm3"]
|
||||
device = -1
|
||||
@@ -17,6 +16,7 @@ events = [SubResource("InputEventKey_03bm3")]
|
||||
layout_mode = 3
|
||||
offset_right = 641.0
|
||||
offset_bottom = 401.0
|
||||
theme_type_variation = &"PlayerList"
|
||||
script = ExtResource("part_script")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=270084211]
|
||||
@@ -26,55 +26,63 @@ layout_mode = 2
|
||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HeaderBalance" type="Control" parent="VBoxContainer/HBoxContainer"]
|
||||
custom_minimum_size = Vector2(36, 0)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=1331357386]
|
||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 1
|
||||
theme_type_variation = &"TextLarge"
|
||||
text = "Players"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="AddPlayersButton" type="TextureButton" parent="VBoxContainer/HBoxContainer" unique_id=634410148]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||
custom_minimum_size = Vector2(36, 36)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 8
|
||||
size_flags_vertical = 4
|
||||
tooltip_text = "Add or remove players (Alt+A)"
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
shortcut = SubResource("Shortcut_jafqj")
|
||||
texture_normal = ExtResource("5_31tjv")
|
||||
ignore_texture_size = true
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="AddPlayerContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=1371948739]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||
custom_minimum_size = Vector2(0, 36)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
alignment = 1
|
||||
|
||||
[node name="Panel" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=1569854056]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"DarkSpacer"
|
||||
|
||||
[node name="PlayerNameField" type="TextEdit" parent="VBoxContainer/AddPlayerContainer" unique_id=332535327]
|
||||
[node name="PlayerNameField" type="LineEdit" parent="VBoxContainer/AddPlayerContainer" unique_id=332535327]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||
custom_minimum_size = Vector2(220, 0)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Player name"
|
||||
script = ExtResource("7_qsdfy")
|
||||
max_length = 10
|
||||
|
||||
[node name="AddPlayerButton" type="Button" parent="VBoxContainer/AddPlayerContainer" unique_id=245001313]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Add"
|
||||
|
||||
[node name="Panel2" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=60215448]
|
||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||
[node name="AddPlayerHint" type="Label" parent="VBoxContainer/AddPlayerContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"DarkSpacer"
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Up to 7 players"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer" unique_id=1000000001]
|
||||
layout_mode = 2
|
||||
@@ -84,5 +92,6 @@ horizontal_scroll_mode = 0
|
||||
[node name="PlayersListVBoxContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer" unique_id=1000000002]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 15
|
||||
+42
-62
@@ -3,7 +3,8 @@
|
||||
[ext_resource type="Script" path="res://quiz/quiz.gd" id="quiz_script"]
|
||||
[ext_resource type="PackedScene" uid="uid://ds15cgsf8vpvc" path="res://player/MusicPlayer.tscn" id="5_emn36"]
|
||||
[ext_resource type="PackedScene" uid="uid://cdy4kvemwaiom" path="res://popups/CharacterSelectPopup.tscn" id="6_dhrvg"]
|
||||
[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://popups/SettingsPopup.tscn" id="11_k62u5"]
|
||||
[ext_resource type="PackedScene" path="res://quiz/QuizSettingsCard.tscn" id="scene_QuizSettingsCard"]
|
||||
[ext_resource type="Texture2D" path="res://icons/settings_light_icon.svg" id="settings_icon"]
|
||||
[ext_resource type="PackedScene" uid="uid://cxlsmxnrp0agh" path="res://popups/WinnerPopup.tscn" id="17_winner_window"]
|
||||
[ext_resource type="PackedScene" path="res://quiz/MusicListPanel.tscn" id="scene_MusicListPanel"]
|
||||
[ext_resource type="PackedScene" path="res://quiz/PlayersPanel.tscn" id="scene_PlayersPanel"]
|
||||
@@ -23,113 +24,92 @@ grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("quiz_script")
|
||||
|
||||
[node name="Open" type="Button" parent="." unique_id=165052329]
|
||||
visible = false
|
||||
layout_mode = 0
|
||||
offset_left = 1296.0
|
||||
offset_top = 66.0
|
||||
offset_right = 1346.0
|
||||
offset_bottom = 97.0
|
||||
text = "Open"
|
||||
|
||||
[node name="FileDialog" type="FileDialog" parent="." unique_id=485771559]
|
||||
title = "Open a Directory"
|
||||
initial_position = 2
|
||||
size = Vector2i(616, 400)
|
||||
ok_button_text = "Select Current Folder"
|
||||
file_mode = 2
|
||||
access = 2
|
||||
show_hidden_files = true
|
||||
|
||||
[node name="MusicListPanel" parent="." unique_id=2011292375 instance=ExtResource("scene_MusicListPanel")]
|
||||
layout_mode = 0
|
||||
offset_left = 64.0
|
||||
offset_top = 384.0
|
||||
offset_right = 1152.0
|
||||
offset_bottom = 896.0
|
||||
offset_bottom = 894.0
|
||||
|
||||
[node name="InspirationList" parent="." unique_id=498283761 instance=ExtResource("scene_InspirationList")]
|
||||
layout_mode = 0
|
||||
offset_left = 1216.0
|
||||
offset_top = 128.0
|
||||
offset_right = 1857.0
|
||||
offset_right = 1856.0
|
||||
offset_bottom = 512.0
|
||||
|
||||
[node name="NowPlaying" parent="." unique_id=42468498 instance=ExtResource("scene_NowPlaying")]
|
||||
layout_mode = 0
|
||||
offset_left = 64.0
|
||||
offset_top = 64.0
|
||||
offset_top = 56.0
|
||||
offset_right = 1152.0
|
||||
offset_bottom = 384.0
|
||||
|
||||
[node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")]
|
||||
layout_mode = 0
|
||||
offset_left = 64.0
|
||||
offset_top = 992.0
|
||||
offset_top = 970.0
|
||||
offset_right = 1856.0
|
||||
offset_bottom = 1063.0
|
||||
offset_bottom = 1012.0
|
||||
|
||||
[node name="PlayersPanel" parent="." unique_id=1708375945 instance=ExtResource("scene_PlayersPanel")]
|
||||
layout_mode = 0
|
||||
offset_left = 1216.0
|
||||
offset_top = 576.0
|
||||
offset_right = 1857.0
|
||||
offset_bottom = 977.0
|
||||
offset_top = 544.0
|
||||
offset_right = 1856.0
|
||||
offset_bottom = 954.0
|
||||
|
||||
[node name="QuizToolbar" parent="." unique_id=1873301941 instance=ExtResource("scene_QuizToolbar")]
|
||||
layout_mode = 0
|
||||
offset_left = 1360.0
|
||||
offset_left = 1216.0
|
||||
offset_top = 64.0
|
||||
offset_right = 1855.0
|
||||
offset_bottom = 95.0
|
||||
offset_right = 1856.0
|
||||
offset_bottom = 100.0
|
||||
|
||||
[node name="SongControls" parent="." unique_id=1640212773 instance=ExtResource("scene_SongControls")]
|
||||
layout_mode = 0
|
||||
offset_left = 65.0
|
||||
offset_top = 944.0
|
||||
offset_right = 368.0
|
||||
offset_bottom = 975.0
|
||||
|
||||
[node name="SettingsPopup" parent="." unique_id=1323246978 instance=ExtResource("11_k62u5")]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 720.0
|
||||
offset_top = 153.0
|
||||
offset_right = 720.0
|
||||
offset_bottom = 153.0
|
||||
offset_left = 64.0
|
||||
offset_top = 918.0
|
||||
offset_right = 704.0
|
||||
offset_bottom = 954.0
|
||||
|
||||
[node name="CharacterSelectPopup" parent="." unique_id=1679856322 instance=ExtResource("6_dhrvg")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 405.0
|
||||
offset_top = 165.0
|
||||
offset_right = 1557.0
|
||||
offset_bottom = 805.0
|
||||
offset_left = 170.0
|
||||
offset_top = 180.0
|
||||
offset_right = 1750.0
|
||||
offset_bottom = 900.0
|
||||
|
||||
[node name="WinnerPopup" parent="." unique_id=999999999 instance=ExtResource("17_winner_window")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 720.0
|
||||
offset_top = 153.0
|
||||
offset_right = 720.0
|
||||
offset_bottom = 153.0
|
||||
offset_left = 160.0
|
||||
offset_top = 80.0
|
||||
offset_right = 160.0
|
||||
offset_bottom = 80.0
|
||||
|
||||
[node name="DebugLabel" type="Label" parent="." unique_id=1959221257]
|
||||
[node name="QuizSettingsButton" type="Button" parent="." unique_id=1323246979]
|
||||
layout_mode = 0
|
||||
offset_left = 64.0
|
||||
offset_top = 1028.0
|
||||
offset_right = 224.0
|
||||
offset_bottom = 1064.0
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Quiz settings"
|
||||
icon = ExtResource("settings_icon")
|
||||
expand_icon = true
|
||||
|
||||
[node name="QuizSettingsCard" parent="." unique_id=1323246980 instance=ExtResource("scene_QuizSettingsCard")]
|
||||
visible = false
|
||||
layout_mode = 0
|
||||
offset_left = 974.0
|
||||
offset_top = 904.0
|
||||
offset_right = 1014.0
|
||||
offset_bottom = 927.0
|
||||
scale = Vector2(3, 3)
|
||||
text = "DEBUG"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
autowrap_mode = 2
|
||||
offset_left = 64.0
|
||||
offset_top = 962.0
|
||||
offset_right = 64.0
|
||||
offset_bottom = 962.0
|
||||
|
||||
[node name="SongLoader" type="Node" parent="." unique_id=1520867349]
|
||||
script = ExtResource("song_loader_script")
|
||||
|
||||
[connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"]
|
||||
@@ -0,0 +1,219 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://quiz/quiz_settings_card.gd" id="1_card_script"]
|
||||
|
||||
[node name="QuizSettingsCard" type="Control" node_paths=PackedStringArray("panel")]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_card_script")
|
||||
panel = NodePath("CardPanel")
|
||||
|
||||
[node name="CardPanel" type="Panel" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_top = -470.0
|
||||
offset_right = 440.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="CardPanel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 16.0
|
||||
offset_top = 12.0
|
||||
offset_right = 424.0
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CardPanel/VBox"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Title"
|
||||
text = "Quiz settings"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="AutoRepeatCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Auto repeat"
|
||||
|
||||
[node name="StopAfterCurrentCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Stop after current"
|
||||
|
||||
[node name="HideNextTrackCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Hide next track"
|
||||
|
||||
[node name="ShortSongsCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
tooltip_text = "Play only a part of each song, from a random point, so more songs fit in a game"
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Short songs"
|
||||
|
||||
[node name="ShortSongSecondsRow" type="HBoxContainer" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="CardPanel/VBox/ShortSongSecondsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Seconds per song"
|
||||
|
||||
[node name="ShortSongSecondsLabel" type="Label" parent="CardPanel/VBox/ShortSongSecondsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "30"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LowerShortSongSecondsButton" type="Button" parent="CardPanel/VBox/ShortSongSecondsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "-5"
|
||||
|
||||
[node name="IncreaseShortSongSecondsButton" type="Button" parent="CardPanel/VBox/ShortSongSecondsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "+5"
|
||||
|
||||
[node name="HideBeginningCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Hide the beginning of songs"
|
||||
|
||||
[node name="HideForSecondsRow" type="HBoxContainer" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="CardPanel/VBox/HideForSecondsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Seconds to hide"
|
||||
|
||||
[node name="HideForSecondsLabel" type="Label" parent="CardPanel/VBox/HideForSecondsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "5"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LowerHideForSecondsButton" type="Button" parent="CardPanel/VBox/HideForSecondsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "-1"
|
||||
|
||||
[node name="IncreaseHideForSecondsButton" type="Button" parent="CardPanel/VBox/HideForSecondsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "+1"
|
||||
|
||||
[node name="HideLengthCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Hide song length"
|
||||
|
||||
[node name="HideTicksCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Hide ticks in the playbar"
|
||||
|
||||
[node name="WinningScoreRow" type="HBoxContainer" parent="CardPanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="CardPanel/VBox/WinningScoreRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Winning score"
|
||||
|
||||
[node name="ScoreLabel" type="Label" parent="CardPanel/VBox/WinningScoreRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "20"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LowerButton" type="Button" parent="CardPanel/VBox/WinningScoreRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "-1"
|
||||
|
||||
[node name="IncreaseButton" type="Button" parent="CardPanel/VBox/WinningScoreRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "+1"
|
||||
|
||||
[node name="InspirationRow" type="HBoxContainer" parent="CardPanel/VBox"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="CardPanel/VBox/InspirationRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_type_variation = &"SettingsRowLabel"
|
||||
text = "Inspiration list speed"
|
||||
|
||||
[node name="InspirationListSpeedLabel" type="Label" parent="CardPanel/VBox/InspirationRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "1"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LowerInspirationSpeedButton" type="Button" parent="CardPanel/VBox/InspirationRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "-1"
|
||||
|
||||
[node name="IncreaseInspirationSpeedButton" type="Button" parent="CardPanel/VBox/InspirationRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(30, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
theme_type_variation = &"StepButton"
|
||||
text = "+1"
|
||||
+21
-23
@@ -2,44 +2,42 @@
|
||||
|
||||
[ext_resource type="Script" path="res://quiz/quiz_toolbar.gd" id="part_script"]
|
||||
|
||||
[node name="QuizToolbar" type="Control" unique_id=1873301941]
|
||||
[sub_resource type="InputEventKey" id="InputEventKey_search"]
|
||||
device = -1
|
||||
alt_pressed = true
|
||||
keycode = 83
|
||||
|
||||
[sub_resource type="Shortcut" id="Shortcut_search"]
|
||||
events = [SubResource("InputEventKey_search")]
|
||||
|
||||
[node name="QuizToolbar" type="HBoxContainer" unique_id=1873301941]
|
||||
layout_mode = 3
|
||||
offset_right = 495.0
|
||||
offset_bottom = 31.0
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 2
|
||||
script = ExtResource("part_script")
|
||||
|
||||
[node name="SearchButton" type="Button" parent="." unique_id=1782453581]
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
shortcut = SubResource("Shortcut_search")
|
||||
text = "Search"
|
||||
|
||||
[node name="ResetPlaylistButton" type="Button" parent="." unique_id=1754155620]
|
||||
layout_mode = 0
|
||||
offset_right = 111.0
|
||||
offset_bottom = 31.0
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Reset playlist"
|
||||
|
||||
[node name="ResetPointsButton" type="Button" parent="." unique_id=942880776]
|
||||
layout_mode = 0
|
||||
offset_left = 128.0
|
||||
offset_right = 239.0
|
||||
offset_bottom = 31.0
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Reset points"
|
||||
|
||||
[node name="SettingsButton" type="Button" parent="." unique_id=1193400323]
|
||||
layout_mode = 0
|
||||
offset_left = 256.0
|
||||
offset_right = 367.0
|
||||
offset_bottom = 31.0
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Settings"
|
||||
|
||||
[node name="SoundTestButton" type="Button" parent="." unique_id=1332971879]
|
||||
layout_mode = 0
|
||||
offset_left = 384.0
|
||||
offset_right = 495.0
|
||||
offset_bottom = 31.0
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Sound test"
|
||||
+6
-11
@@ -20,29 +20,24 @@ unicode = 231
|
||||
[sub_resource type="Shortcut" id="Shortcut_d6fml"]
|
||||
events = [SubResource("InputEventKey_7dnqw")]
|
||||
|
||||
[node name="SongControls" type="Control" unique_id=1640212773]
|
||||
[node name="SongControls" type="HBoxContainer" unique_id=1640212773]
|
||||
layout_mode = 3
|
||||
offset_right = 303.0
|
||||
offset_bottom = 31.0
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 0
|
||||
script = ExtResource("part_script")
|
||||
|
||||
[node name="ShowAnswerButton" type="Button" parent="." unique_id=415388039]
|
||||
layout_mode = 0
|
||||
offset_right = 111.0
|
||||
offset_bottom = 31.0
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
shortcut = SubResource("Shortcut_a7fvb")
|
||||
text = "Show answer"
|
||||
|
||||
[node name="NextButton" type="Button" parent="." unique_id=1744703488]
|
||||
layout_mode = 0
|
||||
offset_left = 128.0
|
||||
offset_right = 303.0
|
||||
offset_bottom = 31.0
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
disabled = true
|
||||
action_mode = 0
|
||||
shortcut = SubResource("Shortcut_d6fml")
|
||||
text = "Randomize new track"
|
||||
text = "Next song"
|
||||
+9
-43
@@ -1,63 +1,29 @@
|
||||
[gd_scene format=3 uid="uid://1jbtmhbqolqe"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://c8r7ykphss4c2" path="res://characters/Link.png" id="1_ll28s"]
|
||||
[ext_resource type="Script" uid="uid://cjo13wixip8mq" path="res://quiz/song_row.gd" id="1_lspki"]
|
||||
|
||||
[node name="SongRow" type="PanelContainer"]
|
||||
custom_minimum_size = Vector2(1000, 0)
|
||||
offset_right = 1088.0
|
||||
offset_bottom = 59.0
|
||||
theme_type_variation = &"SongRow"
|
||||
script = ExtResource("1_lspki")
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="."]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="SongListLabel" type="Label" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
texture_filter = 2
|
||||
custom_minimum_size = Vector2(865, 28)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 1
|
||||
theme_type_variation = &"SongRowLabel"
|
||||
text = "Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv) - 'Crystal Cave -arrange-'' from FINAL FANTASY III"
|
||||
autowrap_mode = 2
|
||||
vertical_alignment = 1
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="Panel" type="Panel" parent="HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 0
|
||||
|
||||
[node name="SongListPlayers" type="HBoxContainer" parent="HBoxContainer"]
|
||||
[node name="SongListPlayers" type="VBoxContainer" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 0
|
||||
|
||||
[node name="TextureRect" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
|
||||
custom_minimum_size = Vector2(50, 25)
|
||||
layout_mode = 2
|
||||
texture = ExtResource("1_ll28s")
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="TextureRect2" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
|
||||
custom_minimum_size = Vector2(50, 25)
|
||||
layout_mode = 2
|
||||
texture = ExtResource("1_ll28s")
|
||||
expand_mode = 1
|
||||
stretch_mode = 6
|
||||
|
||||
[node name="TextureRect3" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
|
||||
custom_minimum_size = Vector2(50, 25)
|
||||
layout_mode = 2
|
||||
texture = ExtResource("1_ll28s")
|
||||
expand_mode = 1
|
||||
|
||||
[node name="TextureRect4" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
|
||||
custom_minimum_size = Vector2(50, 25)
|
||||
layout_mode = 2
|
||||
texture = ExtResource("1_ll28s")
|
||||
expand_mode = 1
|
||||
stretch_mode = 6
|
||||
size_flags_vertical = 4
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"SongRowPictureLines"
|
||||
@@ -48,9 +48,13 @@ func _process(delta: float) -> void:
|
||||
scroll_bar.value = 0
|
||||
|
||||
|
||||
## Fetches every soundtrack from the server and shows them.
|
||||
func load_from_server() -> void:
|
||||
GameLog.debug("load_from_server")
|
||||
## Shows every soundtrack: the music folder's soundtracks when one is picked, otherwise
|
||||
## the server's.
|
||||
func load_soundtracks() -> void:
|
||||
GameLog.debug("load_soundtracks")
|
||||
if Settings.is_offline():
|
||||
show_soundtracks(Settings.music_folder.soundtrack_names())
|
||||
return
|
||||
var populate_list: Callable = func populate_list(array: Variant) -> void:
|
||||
if typeof(array) == TYPE_ARRAY:
|
||||
show_soundtracks(array)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
class_name MusicListPanel
|
||||
extends PanelContainer
|
||||
## The list of songs in the playlist, one row per song.
|
||||
## The list of songs in the playlist, one row per song. A song's row flashes when the
|
||||
## song starts playing and when it is answered.
|
||||
|
||||
## Emitted when the row of song number [param song_no] is clicked.
|
||||
|
||||
signal song_clicked(song_no: int)
|
||||
|
||||
var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
|
||||
# Whether each song was playing and answered when the list was last shown, so a row
|
||||
# can flash when its song starts or is answered
|
||||
var _last_state: Dictionary = {}
|
||||
# When each flash ends, in msec, so a flash goes on when the list is shown again
|
||||
var _flash_ends: Dictionary = {}
|
||||
|
||||
@onready var _rows: VBoxContainer = %MusicList
|
||||
|
||||
@@ -14,11 +20,26 @@ var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
|
||||
## Shows the songs in the playlist again, so it shows their latest state.
|
||||
func refresh() -> void:
|
||||
Settings.delete_children(_rows)
|
||||
for s: SongObject in Playlist.get_playlist():
|
||||
var songs: Array[SongObject] = Playlist.get_playlist()
|
||||
var state: Dictionary = {}
|
||||
for i: int in songs.size():
|
||||
var s: SongObject = songs[i]
|
||||
var new_song_list_object: SongRow = _song_list_object.instantiate()
|
||||
new_song_list_object.song_object = s
|
||||
new_song_list_object.index = i
|
||||
new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number))
|
||||
_rows.add_child(new_song_list_object)
|
||||
state[s] = [s.is_playing, s.is_answered]
|
||||
# Only songs that were listed before flash, not a whole list loaded at once
|
||||
if _last_state.has(s) && _last_state[s] != state[s] \
|
||||
&& (s.is_playing || s.is_answered):
|
||||
_flash_ends[s] = Time.get_ticks_msec() + int(Anim.SLOW * 1000)
|
||||
var left: float = (_flash_ends.get(s, 0) - Time.get_ticks_msec()) / 1000.0
|
||||
if left > 0:
|
||||
Anim.flash(new_song_list_object, get_theme_color(&"flash", &"Palette"), left)
|
||||
else:
|
||||
_flash_ends.erase(s)
|
||||
_last_state = state
|
||||
|
||||
|
||||
func _on_row_input(event: InputEvent, song_no: int) -> void:
|
||||
|
||||
+101
-6
@@ -1,10 +1,105 @@
|
||||
class_name NowPlaying
|
||||
extends VBoxContainer
|
||||
## The soundtrack and song that are playing, in large text at the top of the quiz.
|
||||
extends HBoxContainer
|
||||
## The soundtrack and song that are playing, in large text at the top of the quiz. They
|
||||
## fade in when they change. Once the answer is shown, the soundtrack's cover shows on
|
||||
## their left, when it has one, and the cover and titles are centered together. Both are
|
||||
## centered up and down too, so the titles only move sideways when a cover comes. Each
|
||||
## title has at most two lines.
|
||||
|
||||
## The covers on disk. No cover is shown without it.
|
||||
var covers: CoverCache:
|
||||
set(value):
|
||||
covers = value
|
||||
if covers != null:
|
||||
covers.cover_times_changed.connect(_load_cover)
|
||||
_load_cover()
|
||||
|
||||
# The soundtrack whose cover is shown or on its way, and the cover time asked for
|
||||
var _cover_uuid: String = ""
|
||||
var _cover_time: int = 0
|
||||
|
||||
@onready var soundtrack_label: Label = %SoundtrackLabel
|
||||
@onready var song_label: Label = %SongLabel
|
||||
@onready var cover_frame: Panel = %CoverFrame
|
||||
@onready var cover_picture: TextureRect = %CoverPicture
|
||||
@onready var titles: VBoxContainer = %Titles
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
resized.connect(_fit_titles)
|
||||
_fit_titles()
|
||||
|
||||
|
||||
## Shows [param soundtrack_title] and [param song_title]. The playlist hides them as
|
||||
## question marks until the answer is shown.
|
||||
func show_song(soundtrack_title: String, song_title: String) -> void:
|
||||
$SoundtrackLabel.text = soundtrack_title
|
||||
$SongLabel.text = song_title
|
||||
## question marks until the answer is shown, and only then gives the
|
||||
## [param soundtrack_uuid] whose cover is shown.
|
||||
func show_song(soundtrack_title: String, song_title: String,
|
||||
soundtrack_uuid: String = "") -> void:
|
||||
var changed: bool = soundtrack_label.text != soundtrack_title \
|
||||
|| song_label.text != song_title
|
||||
soundtrack_label.text = soundtrack_title
|
||||
song_label.text = song_title
|
||||
_fit_titles()
|
||||
if soundtrack_uuid != _cover_uuid:
|
||||
_cover_uuid = soundtrack_uuid
|
||||
_cover_time = 0
|
||||
_show_cover(null)
|
||||
_load_cover()
|
||||
# A new song, or the answer being shown, fades in
|
||||
if changed:
|
||||
Anim.fade_in(self, Anim.SLOW)
|
||||
|
||||
|
||||
## Whether a cover is shown.
|
||||
func has_cover() -> bool:
|
||||
return cover_frame.visible
|
||||
|
||||
|
||||
# Asks for the cover, again when its time changed, e.g. once the soundtrack list loaded
|
||||
func _load_cover() -> void:
|
||||
if covers == null || _cover_uuid.is_empty() || !is_node_ready():
|
||||
return
|
||||
var time: int = covers.cover_time(_cover_uuid)
|
||||
if time == _cover_time:
|
||||
return
|
||||
_cover_time = time
|
||||
if time <= 0:
|
||||
_show_cover(null)
|
||||
return
|
||||
covers.fetch_cover(_cover_uuid, time, _show_cover_of.bind(_cover_uuid, time))
|
||||
|
||||
|
||||
# Covers arrive later; one for a song no longer shown is left out
|
||||
func _show_cover_of(cover: Image, uuid: String, time: int) -> void:
|
||||
if uuid == _cover_uuid && time == _cover_time:
|
||||
_show_cover(cover)
|
||||
|
||||
|
||||
func _show_cover(cover: Image) -> void:
|
||||
var was_shown: bool = cover_frame.visible
|
||||
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
|
||||
cover_frame.visible = cover != null
|
||||
_fit_titles()
|
||||
if cover_frame.visible && !was_shown:
|
||||
Anim.fade_in(cover_frame, Anim.SLOW)
|
||||
|
||||
|
||||
# The titles take the width of the longer one, so they stay next to the cover, but no
|
||||
# more than there is room for; longer titles wrap. Beside a cover they line up with it
|
||||
# on the left.
|
||||
func _fit_titles() -> void:
|
||||
var room: float = size.x
|
||||
if cover_frame.visible:
|
||||
room -= cover_frame.custom_minimum_size.x + get_theme_constant(&"separation")
|
||||
var widest: float = maxf(_text_width(soundtrack_label), _text_width(song_label))
|
||||
titles.custom_minimum_size.x = clampf(ceilf(widest), 1.0, maxf(room, 1.0))
|
||||
var alignment: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT if cover_frame.visible \
|
||||
else HORIZONTAL_ALIGNMENT_CENTER
|
||||
soundtrack_label.horizontal_alignment = alignment
|
||||
song_label.horizontal_alignment = alignment
|
||||
|
||||
|
||||
static func _text_width(label: Label) -> float:
|
||||
var font: Font = label.get_theme_font(&"font")
|
||||
return font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1,
|
||||
label.get_theme_font_size(&"font_size")).x
|
||||
@@ -1,49 +0,0 @@
|
||||
class_name PlayerNameField
|
||||
extends TextEdit
|
||||
## The field for a new player's name. It keeps to one line of at most
|
||||
## [constant MAX_NAME_LENGTH] characters, and reports Enter so the player can be added.
|
||||
|
||||
## Emitted when Enter is pressed while the field has focus.
|
||||
|
||||
signal enter_key_pressed
|
||||
|
||||
## Emitted when Alt+A is pressed.
|
||||
signal close_pressed
|
||||
|
||||
## The longest name a player can have.
|
||||
const MAX_NAME_LENGTH: int = 10
|
||||
|
||||
var current_text: String = ""
|
||||
var cursor_line: int = 0
|
||||
var cursor_column: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
text_changed.connect(_on_text_changed)
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed:
|
||||
if event.alt_pressed && event.keycode == KEY_A:
|
||||
close_pressed.emit()
|
||||
if has_focus():
|
||||
if event.keycode == KEY_ENTER || event.keycode == KEY_KP_ENTER:
|
||||
enter_key_pressed.emit()
|
||||
|
||||
|
||||
func _on_text_changed() -> void:
|
||||
if text.contains("\n"):
|
||||
text = text.replace("\n", "")
|
||||
|
||||
var new_text: String = text
|
||||
if new_text.length() > MAX_NAME_LENGTH:
|
||||
text = current_text
|
||||
# when replacing the text, the cursor will get moved to the beginning of the
|
||||
# text, so move it back to where it was
|
||||
set_caret_line(cursor_line)
|
||||
set_caret_column(cursor_column)
|
||||
|
||||
current_text = text
|
||||
# save current position of cursor for when we have reached the limit
|
||||
cursor_line = get_caret_line()
|
||||
cursor_column = get_caret_column()
|
||||
@@ -1 +0,0 @@
|
||||
uid://cgakje8gb37tf
|
||||
+60
-13
@@ -1,5 +1,10 @@
|
||||
class_name PlayerRow
|
||||
extends Control
|
||||
extends PanelContainer
|
||||
## One player in the player list: their name, points and character, and buttons for
|
||||
## giving and taking points. Clicking the character picks another one.
|
||||
##
|
||||
## The score pops when it changes. The first point of a game makes the row flash, and a
|
||||
## player one point from winning glows and says "Match point!" until that changes.
|
||||
|
||||
signal change_character_clicked
|
||||
signal player_removed
|
||||
@@ -12,6 +17,9 @@ var player_object: PlayerObject
|
||||
var list_index: int
|
||||
|
||||
@onready var player_name_field: Label = %Name
|
||||
@onready var score_label: Label = %Score
|
||||
@onready var match_point_tag: Label = %MatchPointTag
|
||||
@onready var character_frame: PanelContainer = %CharacterFrame
|
||||
@onready var add_button: Button = %AddPoint
|
||||
@onready var minus_button: Button = %RemovePoint
|
||||
@onready var character: TextureButton = %Character
|
||||
@@ -25,12 +33,23 @@ func _ready() -> void:
|
||||
minus_button.set_shortcut(create_button_shortcut(list_index, true))
|
||||
character.pressed.connect(change_character)
|
||||
remove_player.pressed.connect(func() -> void: player_removed.emit())
|
||||
player_object.player_point_given.connect(_on_score_changed)
|
||||
player_object.player_point_taken.connect(_on_score_changed)
|
||||
player_object.first_point_triggered.connect(_on_first_point)
|
||||
character.mouse_entered.connect(_set_character_hovered.bind(true))
|
||||
character.mouse_exited.connect(_set_character_hovered.bind(false))
|
||||
# Rows take turns being lighter and darker, like the other lists
|
||||
theme_type_variation = &"ListRowOdd" if list_index % 2 == 1 else &"ListRowEven"
|
||||
player_name_field.text = player_object.player_name
|
||||
player_name_field.tooltip_text = player_object.player_name
|
||||
set_editing(Settings.edit_players)
|
||||
update_score()
|
||||
set_player_character()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
remove_player.visible = Settings.edit_players
|
||||
## Shows the button that removes the player while the player list is being edited.
|
||||
func set_editing(editing: bool) -> void:
|
||||
remove_player.visible = editing
|
||||
|
||||
|
||||
func create_button_shortcut(index: int, ctrl_pressed: bool) -> Shortcut:
|
||||
@@ -57,22 +76,50 @@ func change_character() -> void:
|
||||
change_character_clicked.emit()
|
||||
|
||||
|
||||
func new_character_selected(character_texture: ImageTexture) -> void:
|
||||
func new_character_selected(character_texture: Texture, character_name: String) -> void:
|
||||
GameLog.debug("Back in player list with new")
|
||||
player_object.character = character_texture
|
||||
player_object.set_character(character_texture, character_name)
|
||||
set_player_character()
|
||||
|
||||
|
||||
func set_player_character() -> void:
|
||||
character.custom_minimum_size = Vector2(80, 40)
|
||||
character.ignore_texture_size = true
|
||||
character.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
|
||||
character.texture_normal = player_object.character
|
||||
|
||||
|
||||
func update_score() -> void:
|
||||
if OS.has_feature("debug"):
|
||||
player_name_field.text = "%d %s: %d" % [list_index + 1, player_object.player_name,
|
||||
player_object.player_score]
|
||||
else:
|
||||
player_name_field.text = player_object.player_name + ": " + str(player_object.player_score)
|
||||
score_label.text = str(player_object.player_score)
|
||||
_show_match_point(is_match_point())
|
||||
|
||||
|
||||
## Whether the player is one point from winning. A prepared quiz has no winning score.
|
||||
func is_match_point() -> bool:
|
||||
return !Settings.is_prepared_quiz() && Settings.winning_score > 1 \
|
||||
&& player_object.player_score == Settings.winning_score - 1
|
||||
|
||||
|
||||
func _on_score_changed() -> void:
|
||||
Anim.pop(score_label)
|
||||
|
||||
|
||||
func _on_first_point() -> void:
|
||||
Anim.flash(self, get_theme_color(&"first_point", &"Palette"), Anim.SLOW * 2)
|
||||
Anim.pop(score_label, 1.8, Anim.SLOW)
|
||||
|
||||
|
||||
# The glow and the tag show while the player is on match point
|
||||
func _show_match_point(on: bool) -> void:
|
||||
var glow: Node = get_node_or_null("Pulse")
|
||||
if on == match_point_tag.visible && on == (glow != null):
|
||||
return
|
||||
match_point_tag.visible = on
|
||||
if glow != null:
|
||||
glow.queue_free()
|
||||
glow.name = "PulseEnding"
|
||||
if on:
|
||||
Anim.pulse(self, get_theme_color(&"match_point", &"Palette"))
|
||||
Anim.pop(match_point_tag, 1.4, Anim.SLOW)
|
||||
|
||||
|
||||
func _set_character_hovered(hovered: bool) -> void:
|
||||
character_frame.theme_type_variation = &"PlayerCharacterHover" if hovered \
|
||||
else &"PlayerCharacter"
|
||||
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user