Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0862d160e5 | ||
|
|
f39865fffc | ||
|
|
c03c0e4efe | ||
|
|
06421bb63e | ||
|
|
ef6ac8f1fd | ||
|
|
a735c515bf | ||
|
|
0fc3be79e1 | ||
|
|
ae231975bd | ||
|
|
b9fa3c3d63 | ||
|
|
de94691073 | ||
|
|
94e8dfb602 | ||
|
|
70e970b24c | ||
|
|
e4352a9f34 |
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,
|
`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
|
to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone
|
||||||
who just wants to run the file.
|
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
|
||||||
+29
-1
@@ -4,6 +4,7 @@ extends RefCounted
|
|||||||
## shrinks while it is held down, and can pop when its icon changes.
|
## 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.
|
## 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.
|
## How big a button is under the mouse.
|
||||||
const HOVER_SCALE: Vector2 = Vector2(1.08, 1.08)
|
const HOVER_SCALE: Vector2 = Vector2(1.08, 1.08)
|
||||||
@@ -18,10 +19,32 @@ const POP_DURATION: float = 0.18
|
|||||||
|
|
||||||
const _TWEEN_META: StringName = &"button_bounce_tween"
|
const _TWEEN_META: StringName = &"button_bounce_tween"
|
||||||
const _PIVOT_META: StringName = &"button_bounce_pivot"
|
const _PIVOT_META: StringName = &"button_bounce_pivot"
|
||||||
|
const _ADDED_META: StringName = &"button_bounce_added"
|
||||||
|
|
||||||
|
|
||||||
## Makes [param button] grow under the mouse and shrink while held down.
|
## 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:
|
static func add_to(button: BaseButton) -> void:
|
||||||
|
if button.has_meta(_ADDED_META):
|
||||||
|
return
|
||||||
|
button.set_meta(_ADDED_META, true)
|
||||||
_keep_pivot_centred(button)
|
_keep_pivot_centred(button)
|
||||||
button.mouse_entered.connect(func() -> void:
|
button.mouse_entered.connect(func() -> void:
|
||||||
if !button.disabled:
|
if !button.disabled:
|
||||||
@@ -38,6 +61,8 @@ static func resting_scale(button: BaseButton) -> Vector2:
|
|||||||
|
|
||||||
## Shrinks [param button] and lets it spring back, e.g. when its icon changes.
|
## Shrinks [param button] and lets it spring back, e.g. when its icon changes.
|
||||||
static func pop(button: BaseButton) -> void:
|
static func pop(button: BaseButton) -> void:
|
||||||
|
if !Anim.enabled():
|
||||||
|
return
|
||||||
_keep_pivot_centred(button)
|
_keep_pivot_centred(button)
|
||||||
button.scale = POP_SCALE
|
button.scale = POP_SCALE
|
||||||
var tween: Tween = _new_tween(button)
|
var tween: Tween = _new_tween(button)
|
||||||
@@ -46,6 +71,9 @@ static func pop(button: BaseButton) -> void:
|
|||||||
|
|
||||||
|
|
||||||
static func _scale_to(button: BaseButton, target: Vector2) -> void:
|
static func _scale_to(button: BaseButton, target: Vector2) -> void:
|
||||||
|
if !Anim.enabled():
|
||||||
|
button.scale = Vector2.ONE
|
||||||
|
return
|
||||||
var tween: Tween = _new_tween(button)
|
var tween: Tween = _new_tween(button)
|
||||||
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||||
tween.tween_property(button, ^"scale", target, DURATION)
|
tween.tween_property(button, ^"scale", target, DURATION)
|
||||||
|
|||||||
+19
-62
@@ -14,15 +14,15 @@ extends RefCounted
|
|||||||
## can't be reached at all, the cached pictures are used as they are.
|
## can't be reached at all, the cached pictures are used as they are.
|
||||||
|
|
||||||
const FOLDER_NAME: String = "characters"
|
const FOLDER_NAME: String = "characters"
|
||||||
const INDEX_FILE_NAME: String = "index.json"
|
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
|
## How long a picture is used before downloading it again, when the server can't tell
|
||||||
## whether it changed.
|
## whether it changed.
|
||||||
const MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60
|
const MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60
|
||||||
## The change time of a picture when the server can't tell it.
|
## The change time of a picture when the server can't tell it.
|
||||||
const UNKNOWN_TIME: int = -1
|
const UNKNOWN_TIME: int = FileCache.UNKNOWN_TIME
|
||||||
|
|
||||||
# What index.json holds: name -> {"modified": int, "downloaded": int}
|
# The pictures on disk and their index
|
||||||
var _index: Dictionary = {}
|
var _files: FileCache = FileCache.new(FOLDER_NAME)
|
||||||
# When the server last changed each picture on the list, or UNKNOWN_TIME
|
# When the server last changed each picture on the list, or UNKNOWN_TIME
|
||||||
var _modified: Dictionary[String, int] = {}
|
var _modified: Dictionary[String, int] = {}
|
||||||
# True when the server could not be reached and only cached pictures are shown
|
# True when the server could not be reached and only cached pictures are shown
|
||||||
@@ -34,11 +34,16 @@ static func folder() -> String:
|
|||||||
return Settings.config_dir().path_join(FOLDER_NAME)
|
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
|
## 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
|
## [code]Array[String][/code]. Cached pictures of characters no longer on the server are
|
||||||
## deleted.
|
## deleted.
|
||||||
func load_list(on_done: Callable) -> void:
|
func load_list(on_done: Callable) -> void:
|
||||||
_load_index()
|
_files.load_index()
|
||||||
_cache_only = false
|
_cache_only = false
|
||||||
var details_fetched: Callable = func details_fetched(details: Array) -> void:
|
var details_fetched: Callable = func details_fetched(details: Array) -> void:
|
||||||
_modified.clear()
|
_modified.clear()
|
||||||
@@ -51,9 +56,8 @@ func load_list(on_done: Callable) -> void:
|
|||||||
## Calls [param on_done] with the picture of [param character_name] as an [Image], from
|
## 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.
|
## disk when the cached one is still fresh, otherwise from the server.
|
||||||
func fetch_image(character_name: String, on_done: Callable) -> void:
|
func fetch_image(character_name: String, on_done: Callable) -> void:
|
||||||
var path: String = _file_path(character_name)
|
if (_cache_only || _is_fresh(character_name)) && _files.has_file(character_name):
|
||||||
if (_cache_only || _is_fresh(character_name)) && FileAccess.file_exists(path):
|
var cached: Image = CharacterCard.image_from_bytes(_files.read(character_name))
|
||||||
var cached: Image = CharacterCard.image_from_bytes(FileAccess.get_file_as_bytes(path))
|
|
||||||
if cached != null:
|
if cached != null:
|
||||||
on_done.call(cached)
|
on_done.call(cached)
|
||||||
return
|
return
|
||||||
@@ -63,7 +67,7 @@ func fetch_image(character_name: String, on_done: Callable) -> void:
|
|||||||
if picture == null:
|
if picture == null:
|
||||||
GameLog.warning("Could not load the picture of ", character_name)
|
GameLog.warning("Could not load the picture of ", character_name)
|
||||||
return
|
return
|
||||||
_store(character_name, bytes)
|
_files.store(character_name, bytes, _modified.get(character_name, UNKNOWN_TIME))
|
||||||
on_done.call(picture)
|
on_done.call(picture)
|
||||||
MusicServerClient.fetch_character_image(character_name, image_fetched)
|
MusicServerClient.fetch_character_image(character_name, image_fetched)
|
||||||
|
|
||||||
@@ -84,71 +88,24 @@ func _load_cached_only(on_done: Callable) -> void:
|
|||||||
GameLog.warning("Could not get the characters from the server, showing the cached ones")
|
GameLog.warning("Could not get the characters from the server, showing the cached ones")
|
||||||
_cache_only = true
|
_cache_only = true
|
||||||
var names: Array[String] = []
|
var names: Array[String] = []
|
||||||
for character_name: String in _index:
|
for character_name: String in _files.keys():
|
||||||
if FileAccess.file_exists(_file_path(character_name)):
|
if _files.has_file(character_name):
|
||||||
names.append(character_name)
|
names.append(character_name)
|
||||||
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
|
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
|
||||||
on_done.call(names)
|
on_done.call(names)
|
||||||
|
|
||||||
|
|
||||||
func _list_loaded(on_done: Callable) -> void:
|
func _list_loaded(on_done: Callable) -> void:
|
||||||
var removed: bool = false
|
_files.keep_only(_modified)
|
||||||
for character_name: String in _index.keys():
|
|
||||||
if !_modified.has(character_name):
|
|
||||||
DirAccess.remove_absolute(_file_path(character_name))
|
|
||||||
_index.erase(character_name)
|
|
||||||
removed = true
|
|
||||||
if removed:
|
|
||||||
_save_index()
|
|
||||||
var names: Array[String] = []
|
var names: Array[String] = []
|
||||||
names.assign(_modified.keys())
|
names.assign(_modified.keys())
|
||||||
on_done.call(names)
|
on_done.call(names)
|
||||||
|
|
||||||
|
|
||||||
func _is_fresh(character_name: String) -> bool:
|
func _is_fresh(character_name: String) -> bool:
|
||||||
if !_index.has(character_name):
|
if !_files.keys().has(character_name):
|
||||||
return false
|
return false
|
||||||
var entry: Dictionary = _index[character_name]
|
|
||||||
var modified: int = _modified.get(character_name, UNKNOWN_TIME)
|
var modified: int = _modified.get(character_name, UNKNOWN_TIME)
|
||||||
if modified != UNKNOWN_TIME:
|
if modified != UNKNOWN_TIME:
|
||||||
return int(entry.get("modified", UNKNOWN_TIME)) == modified
|
return _files.modified(character_name) == modified
|
||||||
var age: float = Time.get_unix_time_from_system() - float(entry.get("downloaded", 0))
|
return _files.age(character_name) < MAX_AGE_SECONDS
|
||||||
return age < MAX_AGE_SECONDS
|
|
||||||
|
|
||||||
|
|
||||||
func _store(character_name: String, bytes: PackedByteArray) -> void:
|
|
||||||
DirAccess.make_dir_recursive_absolute(folder())
|
|
||||||
var file: FileAccess = FileAccess.open(_file_path(character_name), FileAccess.WRITE)
|
|
||||||
if file == null:
|
|
||||||
GameLog.warning("Could not cache the picture of ", character_name, ": ",
|
|
||||||
error_string(FileAccess.get_open_error()))
|
|
||||||
return
|
|
||||||
file.store_buffer(bytes)
|
|
||||||
file.close()
|
|
||||||
_index[character_name] = {
|
|
||||||
"modified": _modified.get(character_name, UNKNOWN_TIME),
|
|
||||||
"downloaded": int(Time.get_unix_time_from_system()),
|
|
||||||
}
|
|
||||||
_save_index()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
func _save_index() -> void:
|
|
||||||
var file: FileAccess = FileAccess.open(folder().path_join(INDEX_FILE_NAME),
|
|
||||||
FileAccess.WRITE)
|
|
||||||
if file != null:
|
|
||||||
file.store_string(JSON.stringify(_index, "\t", true))
|
|
||||||
|
|
||||||
|
|
||||||
# The server's names are plain file names, but they are made safe anyway since they
|
|
||||||
# become paths on this computer
|
|
||||||
func _file_path(character_name: String) -> String:
|
|
||||||
return folder().path_join(character_name.validate_filename())
|
|
||||||
@@ -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
|
||||||
@@ -5,23 +5,36 @@ extends RefCounted
|
|||||||
## letters also find the letters in order with others in between.
|
## 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.
|
## 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] = []
|
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.
|
## Compiled patterns for the letters-in-order search, by cleaned search term.
|
||||||
var _regex_cache: Dictionary = {}
|
var _regex_cache: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
## Replaces the names to search in.
|
## Replaces the names to search in. [param alternatives] has the other names of a
|
||||||
func set_names(names: Array) -> void:
|
## soundtrack, by its real name.
|
||||||
|
func set_names(names: Array, alternatives: Dictionary = {}) -> void:
|
||||||
_names.clear()
|
_names.clear()
|
||||||
|
_matched_alternatives.clear()
|
||||||
for soundtrack: String in names:
|
for soundtrack: String in names:
|
||||||
_names.append({
|
_add_name(soundtrack, soundtrack)
|
||||||
"original": soundtrack,
|
for alternative: String in alternatives.get(soundtrack, []):
|
||||||
"lowercase": soundtrack.to_lower(),
|
_add_name(soundtrack, alternative)
|
||||||
"cleaned": clean_term(soundtrack),
|
|
||||||
})
|
|
||||||
|
## 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
|
## Returns the names matching [param search_text], best matches first: plain
|
||||||
@@ -41,11 +54,14 @@ func find_matches(search_text: String) -> Array[String]:
|
|||||||
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null)
|
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null)
|
||||||
|
|
||||||
var matches: Array[String] = []
|
var matches: Array[String] = []
|
||||||
|
_matched_alternatives.clear()
|
||||||
for matches_pass: Callable in passes:
|
for matches_pass: Callable in passes:
|
||||||
for entry: Dictionary in _names:
|
for entry: Dictionary in _names:
|
||||||
if !seen.has(entry.original) && matches_pass.call(entry):
|
if !seen.has(entry.original) && matches_pass.call(entry):
|
||||||
seen[entry.original] = true
|
seen[entry.original] = true
|
||||||
matches.append(entry.original)
|
matches.append(entry.original)
|
||||||
|
if entry.text != entry.original:
|
||||||
|
_matched_alternatives[entry.original] = entry.text
|
||||||
return matches
|
return matches
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +123,15 @@ static func clean_term(term: String) -> String:
|
|||||||
.replace("'n", "and").to_lower()
|
.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
|
# Compiles the letters-in-order pattern for [param search_term], once per term
|
||||||
func _compile_regex_cached(search_term: String) -> RegEx:
|
func _compile_regex_cached(search_term: String) -> RegEx:
|
||||||
if not search_term in _regex_cache:
|
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
|
||||||
+44
-2
@@ -8,19 +8,25 @@ extends RefCounted
|
|||||||
## order or at random, and only the song's name is the answer.
|
## 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
|
## Hidden files and folders, and files the game can't play (see [AudioFile]), are left
|
||||||
## out.
|
## 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 Kind { EMPTY, SOUNDTRACKS, PREPARED_QUIZ }
|
||||||
enum PlayOrder { IN_ORDER, RANDOM }
|
enum PlayOrder { IN_ORDER, RANDOM }
|
||||||
|
|
||||||
## How many files are tried before giving up when files can't be read.
|
## How many files are tried before giving up when files can't be read.
|
||||||
const MAX_TRIES: int = 20
|
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.
|
## The folder's path.
|
||||||
var path: String = ""
|
var path: String = ""
|
||||||
var kind: Kind = Kind.EMPTY
|
var kind: Kind = Kind.EMPTY
|
||||||
## How a prepared quiz is played. Change it with [method set_play_order].
|
## How a prepared quiz is played. Change it with [method set_play_order].
|
||||||
var play_order: PlayOrder = PlayOrder.IN_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
|
# Soundtrack folder: soundtrack name -> paths of its songs
|
||||||
var _soundtracks: Dictionary[String, PackedStringArray] = {}
|
var _soundtracks: Dictionary[String, PackedStringArray] = {}
|
||||||
@@ -35,7 +41,14 @@ var _quiz_next: int = 0
|
|||||||
static func scan(folder_path: String) -> MusicFolder:
|
static func scan(folder_path: String) -> MusicFolder:
|
||||||
var folder: MusicFolder = MusicFolder.new()
|
var folder: MusicFolder = MusicFolder.new()
|
||||||
folder.path = folder_path.trim_suffix("/")
|
folder.path = folder_path.trim_suffix("/")
|
||||||
var subfolders: PackedStringArray = _visible(DirAccess.get_directories_at(folder.path))
|
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():
|
if !subfolders.is_empty():
|
||||||
for subfolder: String in subfolders:
|
for subfolder: String in subfolders:
|
||||||
var songs: PackedStringArray = _songs_in(folder.path.path_join(subfolder), true)
|
var songs: PackedStringArray = _songs_in(folder.path.path_join(subfolder), true)
|
||||||
@@ -86,6 +99,35 @@ func restart() -> void:
|
|||||||
_quiz_next = 0
|
_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
|
## The next song to play, or null when a prepared quiz has played every song or no file
|
||||||
## can be read.
|
## can be read.
|
||||||
func next_song() -> SongObject:
|
func next_song() -> SongObject:
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ var id: String = _generate_uuid()
|
|||||||
|
|
||||||
var is_first_point: bool = true
|
var is_first_point: bool = true
|
||||||
var character: Texture = load(NO_CHARACTER_PATH)
|
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:
|
func _init(new_name: String) -> void:
|
||||||
@@ -28,6 +31,39 @@ func _init(new_name: String) -> void:
|
|||||||
player_score = 0
|
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".
|
## Returns a random version 4 UUID, e.g. "3f2b8c1e-9a4d-4e7f-b2c6-5d8e1f0a7b93".
|
||||||
static func _generate_uuid() -> String:
|
static func _generate_uuid() -> String:
|
||||||
var bytes: PackedByteArray = Crypto.new().generate_random_bytes(16)
|
var bytes: PackedByteArray = Crypto.new().generate_random_bytes(16)
|
||||||
|
|||||||
+25
-9
@@ -5,8 +5,9 @@ extends Node
|
|||||||
## Registered as the [code]Playlist[/code] autoload.
|
## Registered as the [code]Playlist[/code] autoload.
|
||||||
##
|
##
|
||||||
## Downloaded songs are kept on disk in the [code]songs[/code] folder next to the
|
## 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: it is emptied when
|
## settings, not in memory. The folder only holds the current playlist: when the game
|
||||||
## the game starts and when the playlist is reset.
|
## 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"
|
const SONGS_FOLDER_NAME: String = "songs"
|
||||||
|
|
||||||
@@ -25,10 +26,6 @@ var game_start_song: int = 0
|
|||||||
var _song_files_written: int = 0
|
var _song_files_written: int = 0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
clear_song_files()
|
|
||||||
|
|
||||||
|
|
||||||
## The folder downloaded songs are kept in.
|
## The folder downloaded songs are kept in.
|
||||||
static func songs_folder() -> String:
|
static func songs_folder() -> String:
|
||||||
return Settings.config_dir().path_join(SONGS_FOLDER_NAME)
|
return Settings.config_dir().path_join(SONGS_FOLDER_NAME)
|
||||||
@@ -111,6 +108,10 @@ func get_current_soundtrack_title() -> String:
|
|||||||
return song_object_array[currently_playing_song].get_soundtrack_title()
|
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:
|
func get_current_song_title() -> String:
|
||||||
return song_object_array[currently_playing_song].get_song_title()
|
return song_object_array[currently_playing_song].get_song_title()
|
||||||
|
|
||||||
@@ -152,7 +153,11 @@ func add_song_to_song_object(song_no: int, mp3: PackedByteArray) -> void:
|
|||||||
## written, the song is kept in memory instead.
|
## written, the song is kept in memory instead.
|
||||||
func store_song(song_object: SongObject, mp3: PackedByteArray) -> void:
|
func store_song(song_object: SongObject, mp3: PackedByteArray) -> void:
|
||||||
DirAccess.make_dir_recursive_absolute(songs_folder())
|
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)
|
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
|
_song_files_written += 1
|
||||||
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||||
if file == null:
|
if file == null:
|
||||||
@@ -169,12 +174,23 @@ func store_song(song_object: SongObject, mp3: PackedByteArray) -> void:
|
|||||||
song_object.song_path = path
|
song_object.song_path = path
|
||||||
|
|
||||||
|
|
||||||
## Deletes the files of the songs downloaded so far.
|
## Deletes the files of the songs downloaded so far, except those in [param keep].
|
||||||
func clear_song_files() -> void:
|
## 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()):
|
if !DirAccess.dir_exists_absolute(songs_folder()):
|
||||||
return
|
return
|
||||||
for file_name: String in DirAccess.get_files_at(songs_folder()):
|
for file_name: String in DirAccess.get_files_at(songs_folder()):
|
||||||
DirAccess.remove_absolute(songs_folder().path_join(file_name))
|
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:
|
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
|
||||||
+63
-3
@@ -3,6 +3,13 @@ extends Node
|
|||||||
##
|
##
|
||||||
## Registered as the [code]Settings[/code] autoload.
|
## 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
|
const MAX_NUMBER_OF_PLAYERS: int = 7
|
||||||
## How many letters a search needs before it also finds names with the letters in order
|
## 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
|
## but apart, e.g. "lttp" for "A Link to the Past". Shorter searches only find names
|
||||||
@@ -23,11 +30,19 @@ const SAVED_SETTINGS: Dictionary[String, Array] = {
|
|||||||
"quiz": [
|
"quiz": [
|
||||||
&"auto_repeat_song", &"stop_after_current", &"hide_next_track", &"hide_beginning",
|
&"auto_repeat_song", &"stop_after_current", &"hide_next_track", &"hide_beginning",
|
||||||
&"hide_for_seconds", &"hide_length", &"hide_ticks", &"winning_score",
|
&"hide_for_seconds", &"hide_length", &"hide_ticks", &"winning_score",
|
||||||
&"inspiration_list_speed",
|
&"inspiration_list_speed", &"short_songs", &"short_song_seconds",
|
||||||
],
|
],
|
||||||
"window": [&"fullscreen"],
|
"window": [&"fullscreen"],
|
||||||
"app": [&"last_seen_version"],
|
"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
|
## 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.
|
## 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"
|
const CONFIG_DIR_ENV: String = "MUSICPLAYER_CONFIG_DIR"
|
||||||
@@ -66,7 +81,8 @@ var stop_after_current: bool = true
|
|||||||
var auto_repeat_song: bool = true
|
var auto_repeat_song: bool = true
|
||||||
var hide_next_track: bool = true
|
var hide_next_track: bool = true
|
||||||
var add_to_stats: 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 winning_score: int = 20
|
||||||
var fullscreen: bool = false
|
var fullscreen: bool = false
|
||||||
var inspiration_list_speed: int = 1
|
var inspiration_list_speed: int = 1
|
||||||
@@ -74,6 +90,10 @@ var hide_for_seconds: float = 5.0
|
|||||||
var hide_beginning: bool = false
|
var hide_beginning: bool = false
|
||||||
var hide_length: bool = false
|
var hide_length: bool = false
|
||||||
var hide_ticks: 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 player_array: Array[PlayerObject]
|
||||||
var edit_players: bool = false
|
var edit_players: bool = false
|
||||||
var currently_syncing: bool = false
|
var currently_syncing: bool = false
|
||||||
@@ -85,9 +105,16 @@ var queue_ids: Dictionary[String, String] = {}
|
|||||||
## The version the game last showed the About popup for. When it is not [member version],
|
## 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.
|
## this version is new and the main window opens the About popup to show what is new.
|
||||||
var last_seen_version: String = ""
|
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
|
## 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.
|
## to use the server. Kept for this session only.
|
||||||
var music_folder: MusicFolder = null
|
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.
|
## The game version, set in Project Settings > Application > Config > Version.
|
||||||
## Releases are made from it, see RELEASING.md.
|
## Releases are made from it, see RELEASING.md.
|
||||||
@@ -100,6 +127,7 @@ var version: String = ProjectSettings.get_setting("application/config/version",
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
apply_config(load_config())
|
apply_config(load_config())
|
||||||
load_saved_settings()
|
load_saved_settings()
|
||||||
|
_use_theme(theme_id)
|
||||||
if fullscreen:
|
if fullscreen:
|
||||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
|
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
|
||||||
|
|
||||||
@@ -233,6 +261,38 @@ func is_prepared_quiz() -> bool:
|
|||||||
return music_folder != null && music_folder.kind == MusicFolder.Kind.PREPARED_QUIZ
|
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:
|
func delete_children(node: Node) -> void:
|
||||||
for n: Node in node.get_children():
|
for n: Node in node.get_children():
|
||||||
node.remove_child(n)
|
node.remove_child(n)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ class_name SongObject
|
|||||||
var song_number: int
|
var song_number: int
|
||||||
var song_title: String
|
var song_title: String
|
||||||
var soundtrack_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.
|
## Ids of the players who got a point for this song.
|
||||||
var players_given_point: Array[String] = []
|
var players_given_point: Array[String] = []
|
||||||
@@ -17,6 +19,9 @@ var is_playing: bool = false
|
|||||||
var song_path: String = ""
|
var song_path: String = ""
|
||||||
## The song itself, only when it could not be saved to a file.
|
## The song itself, only when it could not be saved to a file.
|
||||||
var song: AudioStream
|
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:
|
func get_soundtrack_title() -> String:
|
||||||
@@ -33,6 +38,11 @@ func get_song_title() -> String:
|
|||||||
return "??????"
|
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:
|
func get_song_info() -> String:
|
||||||
if Settings.is_debug:
|
if Settings.is_debug:
|
||||||
return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(),
|
return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(),
|
||||||
@@ -61,6 +71,37 @@ func remove_point(player_id: String) -> void:
|
|||||||
players_given_point.erase(player_id)
|
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:
|
func _to_string() -> String:
|
||||||
var format_string: String = "song_number: %d | soundtrack_title: %s | song_title: %s" \
|
var format_string: String = "song_number: %d | soundtrack_title: %s | song_title: %s" \
|
||||||
+ " | is_answered: %s | has_played: %s | is_playing: %s"
|
+ " | is_answered: %s | has_played: %s | is_playing: %s"
|
||||||
|
|||||||
+3
-263
@@ -75,266 +75,6 @@ debug/export_console_script=1
|
|||||||
|
|
||||||
[preset.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"
|
name="Web"
|
||||||
platform="Web"
|
platform="Web"
|
||||||
runnable=false
|
runnable=false
|
||||||
@@ -353,7 +93,7 @@ encrypt_pck=false
|
|||||||
encrypt_directory=false
|
encrypt_directory=false
|
||||||
script_export_mode=2
|
script_export_mode=2
|
||||||
|
|
||||||
[preset.2.options]
|
[preset.1.options]
|
||||||
|
|
||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
@@ -380,7 +120,7 @@ dotnet/include_scripts_content=false
|
|||||||
dotnet/include_debug_symbols=true
|
dotnet/include_debug_symbols=true
|
||||||
dotnet/embed_build_outputs=false
|
dotnet/embed_build_outputs=false
|
||||||
|
|
||||||
[preset.3]
|
[preset.2]
|
||||||
|
|
||||||
name="Linux"
|
name="Linux"
|
||||||
platform="Linux"
|
platform="Linux"
|
||||||
@@ -400,7 +140,7 @@ encrypt_pck=false
|
|||||||
encrypt_directory=false
|
encrypt_directory=false
|
||||||
script_export_mode=2
|
script_export_mode=2
|
||||||
|
|
||||||
[preset.3.options]
|
[preset.2.options]
|
||||||
|
|
||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
[ext_resource type="PackedScene" path="res://statistics/StatisticsView.tscn" id="statistics_view_scene"]
|
[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" 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" 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]
|
[node name="MainWindow" type="Control" unique_id=407149696]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
@@ -109,3 +110,12 @@ offset_left = 610.0
|
|||||||
offset_top = 140.0
|
offset_top = 140.0
|
||||||
offset_right = 610.0
|
offset_right = 610.0
|
||||||
offset_bottom = 140.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
|
||||||
+48
-42
@@ -17,6 +17,7 @@ script = ExtResource("top_bar_script")
|
|||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_right = 1920.0
|
offset_right = 1920.0
|
||||||
offset_bottom = 56.0
|
offset_bottom = 56.0
|
||||||
|
theme_type_variation = &"TopBarBackground"
|
||||||
|
|
||||||
[node name="Title" type="Label" parent="." unique_id=1187907861]
|
[node name="Title" type="Label" parent="." unique_id=1187907861]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
@@ -39,19 +40,27 @@ theme_type_variation = &"HighlightLarge"
|
|||||||
text = "DEBUG"
|
text = "DEBUG"
|
||||||
vertical_alignment = 1
|
vertical_alignment = 1
|
||||||
|
|
||||||
[node name="UpdateLabel" type="Label" parent="." unique_id=1959221258]
|
[node name="UpdateBox" type="HBoxContainer" parent="." unique_id=1959221258]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 1096.0
|
offset_left = 1096.0
|
||||||
offset_top = 12.0
|
offset_top = 12.0
|
||||||
offset_right = 1432.0
|
offset_right = 1432.0
|
||||||
offset_bottom = 50.0
|
offset_bottom = 50.0
|
||||||
mouse_filter = 1
|
mouse_filter = 2
|
||||||
theme_type_variation = &"HighlightMedium"
|
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 = "Version 0.0.0 is available"
|
||||||
horizontal_alignment = 2
|
|
||||||
vertical_alignment = 1
|
|
||||||
text_overrun_behavior = 3
|
text_overrun_behavior = 3
|
||||||
|
clip_text = true
|
||||||
|
alignment = 2
|
||||||
|
|
||||||
[node name="OfflineLabel" type="Label" parent="." unique_id=1959221259]
|
[node name="OfflineLabel" type="Label" parent="." unique_id=1959221259]
|
||||||
visible = false
|
visible = false
|
||||||
@@ -67,62 +76,58 @@ horizontal_alignment = 2
|
|||||||
vertical_alignment = 1
|
vertical_alignment = 1
|
||||||
text_overrun_behavior = 3
|
text_overrun_behavior = 3
|
||||||
|
|
||||||
[node name="SyncButton" type="Button" parent="." unique_id=1228313556]
|
[node name="Buttons" type="HBoxContainer" parent="."]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 1450.0
|
offset_left = 1200.0
|
||||||
offset_top = 8.0
|
offset_top = 10.0
|
||||||
offset_right = 1520.0
|
offset_right = 1856.0
|
||||||
offset_bottom = 48.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
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "Sync"
|
text = "Sync"
|
||||||
|
|
||||||
[node name="SettingsButton" type="Button" parent="." unique_id=1228313557]
|
[node name="SettingsButton" type="Button" parent="Buttons" unique_id=1228313557]
|
||||||
layout_mode = 0
|
unique_name_in_owner = true
|
||||||
offset_left = 1538.0
|
layout_mode = 2
|
||||||
offset_top = 8.0
|
|
||||||
offset_right = 1608.0
|
|
||||||
offset_bottom = 48.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "Settings"
|
text = "Settings"
|
||||||
|
|
||||||
[node name="ShortcutsButton" type="Button" parent="." unique_id=2066498226]
|
[node name="ShortcutsButton" type="Button" parent="Buttons" unique_id=2066498226]
|
||||||
layout_mode = 0
|
unique_name_in_owner = true
|
||||||
offset_left = 1626.0
|
layout_mode = 2
|
||||||
offset_top = 8.0
|
|
||||||
offset_right = 1696.0
|
|
||||||
offset_bottom = 48.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "Shortcuts"
|
text = "Shortcuts"
|
||||||
|
|
||||||
[node name="AboutButton" type="Button" parent="." unique_id=1057035253]
|
[node name="AboutButton" type="Button" parent="Buttons" unique_id=1057035253]
|
||||||
layout_mode = 0
|
unique_name_in_owner = true
|
||||||
offset_left = 1722.0
|
layout_mode = 2
|
||||||
offset_top = 8.0
|
|
||||||
offset_right = 1792.0
|
|
||||||
offset_bottom = 48.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "About"
|
text = "About"
|
||||||
|
|
||||||
[node name="QRButton" type="Button" parent="." unique_id=2021908559]
|
[node name="QRButton" type="Button" parent="Buttons" unique_id=2021908559]
|
||||||
layout_mode = 0
|
unique_name_in_owner = true
|
||||||
offset_left = 1802.0
|
layout_mode = 2
|
||||||
offset_top = 8.0
|
|
||||||
offset_right = 1872.0
|
|
||||||
offset_bottom = 48.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "QR"
|
text = "QR"
|
||||||
|
|
||||||
[node name="QuizTab" type="Button" parent="." unique_id=1903412671]
|
[node name="QuizTab" type="Button" parent="." unique_id=1903412671]
|
||||||
|
unique_name_in_owner = true
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 840.0
|
offset_left = 839.0
|
||||||
offset_top = 8.0
|
offset_top = 10.0
|
||||||
offset_right = 955.0
|
offset_right = 954.0
|
||||||
offset_bottom = 48.0
|
offset_bottom = 46.0
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
theme_type_variation = &"TabButton"
|
theme_type_variation = &"TabButton"
|
||||||
toggle_mode = true
|
toggle_mode = true
|
||||||
@@ -132,11 +137,12 @@ button_group = SubResource("ButtonGroup_tabs")
|
|||||||
text = "Quiz"
|
text = "Quiz"
|
||||||
|
|
||||||
[node name="StatisticsTab" type="Button" parent="." unique_id=1903412672]
|
[node name="StatisticsTab" type="Button" parent="." unique_id=1903412672]
|
||||||
|
unique_name_in_owner = true
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 965.0
|
offset_left = 966.0
|
||||||
offset_top = 8.0
|
offset_top = 10.0
|
||||||
offset_right = 1080.0
|
offset_right = 1081.0
|
||||||
offset_bottom = 48.0
|
offset_bottom = 46.0
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
theme_type_variation = &"TabButton"
|
theme_type_variation = &"TabButton"
|
||||||
toggle_mode = true
|
toggle_mode = true
|
||||||
|
|||||||
+47
-1
@@ -5,6 +5,9 @@ extends Control
|
|||||||
##
|
##
|
||||||
## Everything that is not part of a quiz lives here.
|
## 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.
|
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
|
||||||
var popup_backdrop: PopupBackdrop
|
var popup_backdrop: PopupBackdrop
|
||||||
|
|
||||||
@@ -17,12 +20,15 @@ var popup_backdrop: PopupBackdrop
|
|||||||
@onready var about_popup: AboutPopup = %AboutPopup
|
@onready var about_popup: AboutPopup = %AboutPopup
|
||||||
@onready var log_popup: LogPopup = %LogPopup
|
@onready var log_popup: LogPopup = %LogPopup
|
||||||
@onready var settings_popup: SettingsPopup = %SettingsPopup
|
@onready var settings_popup: SettingsPopup = %SettingsPopup
|
||||||
|
@onready var update_popup: UpdatePopup = %UpdatePopup
|
||||||
@onready var statistics_view: StatisticsView = $StatisticsView
|
@onready var statistics_view: StatisticsView = $StatisticsView
|
||||||
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
|
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
popup_backdrop = PopupBackdrop.add_to(self, _popups())
|
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"):
|
if OS.has_feature("macos"):
|
||||||
GameLog.debug("is_mac")
|
GameLog.debug("is_mac")
|
||||||
quiz.search_pressed.connect(show_search)
|
quiz.search_pressed.connect(show_search)
|
||||||
@@ -32,7 +38,11 @@ func _ready() -> void:
|
|||||||
top_bar.about_pressed.connect(show_about)
|
top_bar.about_pressed.connect(show_about)
|
||||||
top_bar.qr_pressed.connect(show_qr)
|
top_bar.qr_pressed.connect(show_qr)
|
||||||
top_bar.settings_pressed.connect(settings_popup.open)
|
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.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)
|
settings_popup.visibility_changed.connect(_on_settings_popup_visibility_changed)
|
||||||
|
|
||||||
sync_popup.sync_started.connect(_on_sync_started)
|
sync_popup.sync_started.connect(_on_sync_started)
|
||||||
@@ -41,11 +51,41 @@ func _ready() -> void:
|
|||||||
quiz.server_changed.connect(_on_music_source_changed)
|
quiz.server_changed.connect(_on_music_source_changed)
|
||||||
quiz.event_logged.connect(log_popup.add_log_row)
|
quiz.event_logged.connect(log_popup.add_log_row)
|
||||||
statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack)
|
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()
|
search_popup.get_list_of_soundtracks()
|
||||||
show_about_if_new_version()
|
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.
|
# After another server or a music folder was picked. Statistics need the server.
|
||||||
func _on_music_source_changed() -> void:
|
func _on_music_source_changed() -> void:
|
||||||
top_bar.update_offline_state()
|
top_bar.update_offline_state()
|
||||||
@@ -76,7 +116,7 @@ func _input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
func _popups() -> Array[BasePopup]:
|
func _popups() -> Array[BasePopup]:
|
||||||
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
|
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
|
||||||
soundtrack_stats_popup, settings_popup]
|
soundtrack_stats_popup, settings_popup, update_popup]
|
||||||
|
|
||||||
|
|
||||||
# How many songs to download ahead can be changed in the settings. Waiting until the
|
# How many songs to download ahead can be changed in the settings. Waiting until the
|
||||||
@@ -111,8 +151,14 @@ func show_qr() -> void:
|
|||||||
func show_tab(tab: StringName) -> void:
|
func show_tab(tab: StringName) -> void:
|
||||||
GameLog.debug("show_tab ", tab)
|
GameLog.debug("show_tab ", tab)
|
||||||
top_bar.select_tab(tab)
|
top_bar.select_tab(tab)
|
||||||
|
var was_statistics: bool = statistics_view.visible
|
||||||
statistics_view.visible = tab == &"statistics"
|
statistics_view.visible = tab == &"statistics"
|
||||||
quiz.visible = !statistics_view.visible
|
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:
|
if statistics_view.visible:
|
||||||
statistics_view.refresh()
|
statistics_view.refresh()
|
||||||
|
|
||||||
|
|||||||
+37
-13
@@ -16,26 +16,40 @@ signal about_pressed
|
|||||||
signal qr_pressed
|
signal qr_pressed
|
||||||
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
|
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
|
||||||
signal tab_selected(tab: StringName)
|
signal tab_selected(tab: StringName)
|
||||||
|
## Emitted when "Version X is available" is clicked, with the newest version.
|
||||||
|
signal update_pressed(latest_version: String)
|
||||||
|
|
||||||
@onready var sync_button: Button = $SyncButton
|
## Space between the message in the middle and the tabs and buttons around it.
|
||||||
|
const MIDDLE_GAP: float = 16.0
|
||||||
|
|
||||||
|
## The newest release the server knows of, once asked, or empty.
|
||||||
|
var latest_version: String = ""
|
||||||
|
|
||||||
|
@onready var sync_button: Button = %SyncButton
|
||||||
@onready var debug_label: Label = $DebugLabel
|
@onready var debug_label: Label = $DebugLabel
|
||||||
@onready var update_label: Label = $UpdateLabel
|
@onready var update_box: HBoxContainer = $UpdateBox
|
||||||
|
@onready var update_button: Button = %UpdateButton
|
||||||
@onready var offline_label: Label = $OfflineLabel
|
@onready var offline_label: Label = $OfflineLabel
|
||||||
@onready var qr_button: Button = $QRButton
|
@onready var qr_button: Button = %QRButton
|
||||||
@onready var quiz_tab: Button = $QuizTab
|
@onready var quiz_tab: Button = %QuizTab
|
||||||
@onready var statistics_tab: Button = $StatisticsTab
|
@onready var statistics_tab: Button = %StatisticsTab
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Debug builds use the local server, see Settings
|
# Debug builds use the local server, see Settings
|
||||||
debug_label.visible = Settings.is_debug
|
debug_label.visible = Settings.is_debug
|
||||||
sync_button.pressed.connect(sync_pressed.emit)
|
sync_button.pressed.connect(sync_pressed.emit)
|
||||||
$SettingsButton.pressed.connect(settings_pressed.emit)
|
%SettingsButton.pressed.connect(settings_pressed.emit)
|
||||||
$ShortcutsButton.pressed.connect(shortcuts_pressed.emit)
|
%ShortcutsButton.pressed.connect(shortcuts_pressed.emit)
|
||||||
$AboutButton.pressed.connect(about_pressed.emit)
|
%AboutButton.pressed.connect(about_pressed.emit)
|
||||||
qr_button.pressed.connect(qr_pressed.emit)
|
qr_button.pressed.connect(qr_pressed.emit)
|
||||||
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
|
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
|
||||||
statistics_tab.pressed.connect(tab_selected.emit.bind(&"statistics"))
|
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()
|
update_offline_state()
|
||||||
check_for_update()
|
check_for_update()
|
||||||
|
|
||||||
@@ -51,14 +65,14 @@ func update_offline_state() -> void:
|
|||||||
if offline:
|
if offline:
|
||||||
offline_label.text = "Offline: " + Settings.music_folder.display_name()
|
offline_label.text = "Offline: " + Settings.music_folder.display_name()
|
||||||
offline_label.tooltip_text = Settings.music_folder.path
|
offline_label.tooltip_text = Settings.music_folder.path
|
||||||
update_label.visible = false
|
update_box.visible = false
|
||||||
|
|
||||||
|
|
||||||
## Asks the server for the newest MusicPlayer release and tells the user when it is newer
|
## 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.
|
## than this one. Not asked while a music folder is used.
|
||||||
func check_for_update() -> void:
|
func check_for_update() -> void:
|
||||||
if Settings.is_offline():
|
if Settings.is_offline():
|
||||||
update_label.visible = false
|
update_box.visible = false
|
||||||
return
|
return
|
||||||
MusicServerClient.fetch_latest_client_version(show_latest_version)
|
MusicServerClient.fetch_latest_client_version(show_latest_version)
|
||||||
|
|
||||||
@@ -68,10 +82,20 @@ func check_for_update() -> void:
|
|||||||
func show_latest_version(latest: Variant) -> void:
|
func show_latest_version(latest: Variant) -> void:
|
||||||
var newer: bool = latest is String && is_newer_version(latest, Settings.version) \
|
var newer: bool = latest is String && is_newer_version(latest, Settings.version) \
|
||||||
&& !Settings.is_offline()
|
&& !Settings.is_offline()
|
||||||
update_label.visible = newer
|
update_box.visible = newer
|
||||||
if newer:
|
if newer:
|
||||||
update_label.text = "Version %s is available" % latest
|
latest_version = latest
|
||||||
update_label.tooltip_text = "You have %s" % Settings.version
|
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
|
## Whether version [param latest] is newer than [param current], comparing numbers such as
|
||||||
|
|||||||
@@ -109,14 +109,16 @@ theme_type_variation = &"PlayerTime"
|
|||||||
text = "00:00 / 00:00"
|
text = "00:00 / 00:00"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
|
||||||
[node name="VolumeIcon" type="TextureRect" parent="PlayerBar/MusicPlayerContainer"]
|
[node name="VolumeIcon" type="Button" parent="PlayerBar/MusicPlayerContainer"]
|
||||||
custom_minimum_size = Vector2(22, 22)
|
custom_minimum_size = Vector2(22, 22)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_vertical = 4
|
size_flags_vertical = 4
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
texture = ExtResource("6_volume")
|
focus_mode = 0
|
||||||
expand_mode = 1
|
theme_type_variation = &"PlayerIcon"
|
||||||
stretch_mode = 5
|
icon = ExtResource("6_volume")
|
||||||
|
icon_alignment = 1
|
||||||
|
expand_icon = true
|
||||||
|
|
||||||
[node name="VolumeSlider" parent="PlayerBar/MusicPlayerContainer" instance=ExtResource("5_iifuj")]
|
[node name="VolumeSlider" parent="PlayerBar/MusicPlayerContainer" instance=ExtResource("5_iifuj")]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
|
|||||||
+101
-34
@@ -5,6 +5,11 @@ extends Control
|
|||||||
##
|
##
|
||||||
## The buttons grow a little under the mouse and the play button pops when it switches
|
## The buttons grow a little under the mouse and the play button pops when it switches
|
||||||
## between play and pause, see [ButtonBounce].
|
## 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 update_song_list
|
||||||
signal play_next_song
|
signal play_next_song
|
||||||
@@ -16,6 +21,15 @@ var playback_position: float
|
|||||||
var stream: AudioStream
|
var stream: AudioStream
|
||||||
var song_finished: bool = false
|
var song_finished: bool = false
|
||||||
|
|
||||||
|
## 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 play_button: Button = %PlayButton
|
||||||
@onready var restart_button: Button = %RestartButton
|
@onready var restart_button: Button = %RestartButton
|
||||||
@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer
|
@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer
|
||||||
@@ -38,14 +52,46 @@ func _ready() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused:
|
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused \
|
||||||
if !Settings.hide_beginning \
|
&& audio_player.playing:
|
||||||
|| audio_player.get_playback_position() > Settings.hide_for_seconds:
|
var position: float = position_in_part()
|
||||||
progress_slider.value = audio_player.get_playback_position()
|
if _clip_length > 0.0 && position >= _clip_length:
|
||||||
if stream != null:
|
# The short part has played, which counts as the song ending
|
||||||
music_time_label.text = format_text(progress_slider.value, stream.get_length())
|
audio_player.stop()
|
||||||
else:
|
progress_slider.value = _clip_length
|
||||||
music_time_label.text = format_text(progress_slider.value, 0.0)
|
_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.
|
## Whether the play button shows pause, because a song is playing.
|
||||||
@@ -61,6 +107,14 @@ func _show_playing(playing: bool) -> void:
|
|||||||
ButtonBounce.pop(play_button)
|
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:
|
func format_time(time: float) -> String:
|
||||||
var total_seconds: int = roundi(time)
|
var total_seconds: int = roundi(time)
|
||||||
@warning_ignore("integer_division")
|
@warning_ignore("integer_division")
|
||||||
@@ -87,11 +141,7 @@ func play_or_pause() -> void:
|
|||||||
song_finished = false
|
song_finished = false
|
||||||
audio_player.seek(playback_position)
|
audio_player.seek(playback_position)
|
||||||
GameLog.debug("continue")
|
GameLog.debug("continue")
|
||||||
progress_slider.max_value = round(stream.get_length())
|
_show_length()
|
||||||
if Settings.hide_ticks:
|
|
||||||
progress_slider.tick_count = 0
|
|
||||||
else:
|
|
||||||
progress_slider.tick_count = round(stream.get_length() / 60)
|
|
||||||
else:
|
else:
|
||||||
pause()
|
pause()
|
||||||
|
|
||||||
@@ -102,15 +152,15 @@ func pause() -> void:
|
|||||||
_show_playing(false)
|
_show_playing(false)
|
||||||
|
|
||||||
|
|
||||||
|
## Plays the song, or its short part, from the beginning.
|
||||||
func restart() -> void:
|
func restart() -> void:
|
||||||
audio_player.stop()
|
audio_player.stop()
|
||||||
audio_player.stream_paused = false
|
audio_player.stream_paused = false
|
||||||
song_finished = false
|
song_finished = false
|
||||||
progress_slider.value = 0
|
progress_slider.value = 0
|
||||||
playback_position = audio_player.get_playback_position()
|
playback_position = _clip_start
|
||||||
audio_player.seek(playback_position)
|
|
||||||
_show_playing(true)
|
_show_playing(true)
|
||||||
audio_player.play()
|
audio_player.play(_clip_start)
|
||||||
|
|
||||||
|
|
||||||
func _on_finished() -> void:
|
func _on_finished() -> void:
|
||||||
@@ -127,16 +177,18 @@ func _on_drag_started() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _on_drag_ended(_changed: bool) -> void:
|
func _on_drag_ended(_changed: bool) -> void:
|
||||||
audio_player.seek(progress_slider.value)
|
audio_player.seek(_clip_start + progress_slider.value)
|
||||||
playback_position = progress_slider.value
|
playback_position = _clip_start + progress_slider.value
|
||||||
is_changing = false
|
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:
|
func seek(new_position: float) -> void:
|
||||||
progress_slider.value += new_position
|
progress_slider.value += new_position
|
||||||
is_changing = true
|
is_changing = true
|
||||||
audio_player.seek(progress_slider.value)
|
audio_player.seek(_clip_start + progress_slider.value)
|
||||||
playback_position = progress_slider.value
|
playback_position = _clip_start + progress_slider.value
|
||||||
is_changing = false
|
is_changing = false
|
||||||
|
|
||||||
|
|
||||||
@@ -173,6 +225,9 @@ func play_sound(sound_name: AudioStream) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func play_song(song: AudioStream) -> void:
|
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.stream = song
|
||||||
audio_player.play()
|
audio_player.play()
|
||||||
sound_player.stop()
|
sound_player.stop()
|
||||||
@@ -180,32 +235,44 @@ func play_song(song: AudioStream) -> void:
|
|||||||
_show_playing(true)
|
_show_playing(true)
|
||||||
stream = audio_player.stream
|
stream = audio_player.stream
|
||||||
progress_slider.value = 0
|
progress_slider.value = 0
|
||||||
progress_slider.max_value = round(stream.get_length())
|
_show_length()
|
||||||
if Settings.hide_ticks:
|
|
||||||
progress_slider.tick_count = 0
|
|
||||||
else:
|
## Puts song [param song_object_no] in the player without playing it, e.g. the song that
|
||||||
progress_slider.tick_count = round(stream.get_length() / 60)
|
## was playing when the game was closed. Play starts it from the beginning.
|
||||||
music_time_label.text = format_text(progress_slider.value, stream.get_length())
|
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:
|
func play_song_object(song_object_no: int) -> void:
|
||||||
GameLog.debug("play_song_object")
|
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():
|
if audio_player.is_playing():
|
||||||
audio_player.stop()
|
audio_player.stop()
|
||||||
await get_tree().create_timer(0.5).timeout
|
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()
|
sound_player.stop()
|
||||||
audio_player.play()
|
audio_player.play(_clip_start)
|
||||||
song_finished = false
|
song_finished = false
|
||||||
_show_playing(true)
|
_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
|
stream = audio_player.stream
|
||||||
progress_slider.value = 0
|
progress_slider.value = 0
|
||||||
progress_slider.max_value = round(stream.get_length())
|
_show_length()
|
||||||
if Settings.hide_ticks:
|
|
||||||
progress_slider.tick_count = 0
|
|
||||||
else:
|
|
||||||
progress_slider.tick_count = round(stream.get_length() / 60)
|
|
||||||
music_time_label.text = format_text(progress_slider.value, stream.get_length())
|
|
||||||
Playlist.set_currently_playing_song(song_object_no)
|
Playlist.set_currently_playing_song(song_object_no)
|
||||||
Playlist.unset_is_playing()
|
Playlist.unset_is_playing()
|
||||||
if !Settings.hide_next_track:
|
if !Settings.hide_next_track:
|
||||||
|
|||||||
+115
-4
@@ -106,12 +106,71 @@ popup/item_0/id = 0
|
|||||||
popup/item_1/text = "At random"
|
popup/item_1/text = "At random"
|
||||||
popup/item_1/id = 1
|
popup/item_1/id = 1
|
||||||
|
|
||||||
|
[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]
|
[node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/VBoxContainer" unique_id=400304684]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
text = "Fullscreen"
|
text = "Fullscreen"
|
||||||
|
|
||||||
|
[node name="ReduceAnimationsButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
focus_mode = 0
|
||||||
|
action_mode = 0
|
||||||
|
text = "Reduce animations"
|
||||||
|
|
||||||
[node name="CacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer" unique_id=658210492]
|
[node name="CacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer" unique_id=658210492]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
|
||||||
@@ -134,6 +193,7 @@ custom_minimum_size = Vector2(30, 0)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "-1"
|
text = "-1"
|
||||||
|
|
||||||
[node name="IncreaseCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1806295533]
|
[node name="IncreaseCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/CacheRow" unique_id=1806295533]
|
||||||
@@ -142,14 +202,34 @@ custom_minimum_size = Vector2(30, 0)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "+1"
|
text = "+1"
|
||||||
|
|
||||||
[node name="LowPlayedCheckButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
|
[node name="RandomModeRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
|
||||||
unique_name_in_owner = true
|
|
||||||
layout_mode = 2
|
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
|
focus_mode = 0
|
||||||
action_mode = 0
|
selected = 0
|
||||||
text = "Prefer less played soundtracks"
|
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"]
|
[node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
@@ -173,6 +253,27 @@ layout_mode = 2
|
|||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
text = "Open folder"
|
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="."]
|
[node name="FolderDialog" type="FileDialog" parent="."]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
title = "Choose a music folder"
|
title = "Choose a music folder"
|
||||||
@@ -182,3 +283,13 @@ ok_button_text = "Select Current Folder"
|
|||||||
file_mode = 2
|
file_mode = 2
|
||||||
access = 2
|
access = 2
|
||||||
use_native_dialog = true
|
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
|
||||||
@@ -30,19 +30,87 @@ theme_type_variation = &"Title"
|
|||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
text_overrun_behavior = 3
|
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
|
unique_name_in_owner = true
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 20.0
|
offset_left = 20.0
|
||||||
offset_top = 54.0
|
offset_top = 280.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_right = 680.0
|
offset_right = 680.0
|
||||||
offset_bottom = 784.0
|
offset_bottom = 784.0
|
||||||
horizontal_scroll_mode = 0
|
horizontal_scroll_mode = 0
|
||||||
@@ -51,3 +119,181 @@ horizontal_scroll_mode = 0
|
|||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
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"]
|
[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" 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")]
|
[node name="SyncPopup" type="Control" unique_id=1302914356 node_paths=PackedStringArray("panel")]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
@@ -62,11 +63,11 @@ layout_mode = 2
|
|||||||
|
|
||||||
[node name="SyncProgressBar" type="ProgressBar" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer" unique_id=719135231]
|
[node name="SyncProgressBar" type="ProgressBar" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer" unique_id=719135231]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(1300, 0)
|
custom_minimum_size = Vector2(1300, 32)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 6
|
size_flags_horizontal = 6
|
||||||
step = 1.0
|
theme_type_variation = &"SyncProgressBar"
|
||||||
rounded = true
|
script = ExtResource("progress_bar_script")
|
||||||
|
|
||||||
[node name="SyncHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=1156646398]
|
[node name="SyncHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=1156646398]
|
||||||
unique_name_in_owner = true
|
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")
|
||||||
+51
-4
@@ -4,32 +4,79 @@ extends Control
|
|||||||
##
|
##
|
||||||
## Clicking outside [member panel] closes it. Popups that need to prepare or clean up
|
## 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].
|
## 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.
|
## The visible part of the popup. Clicks outside it close the popup.
|
||||||
|
|
||||||
@export var panel: Control
|
@export var panel: Control
|
||||||
|
|
||||||
|
## True while the popup is fading out after [method close].
|
||||||
|
var is_closing: bool = false
|
||||||
|
|
||||||
|
|
||||||
## Shows the popup.
|
## Shows the popup.
|
||||||
func open() -> void:
|
func open() -> void:
|
||||||
|
is_closing = false
|
||||||
visible = true
|
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.
|
## Hides the popup.
|
||||||
func close() -> void:
|
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.
|
## Opens the popup if it is closed and closes it if it is open.
|
||||||
func toggle() -> void:
|
func toggle() -> void:
|
||||||
if visible:
|
if visible && !is_closing:
|
||||||
close()
|
close()
|
||||||
else:
|
else:
|
||||||
open()
|
open()
|
||||||
|
|
||||||
|
|
||||||
|
func _hide() -> void:
|
||||||
|
is_closing = false
|
||||||
|
visible = false
|
||||||
|
panel.modulate.a = 1.0
|
||||||
|
panel.scale = Vector2.ONE
|
||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if !visible || !(event is InputEventMouseButton) || !event.pressed:
|
if !visible || is_closing || !(event is InputEventMouseButton) || !event.pressed:
|
||||||
return
|
return
|
||||||
var click: InputEventMouseButton = panel.make_input_local(event)
|
var click: InputEventMouseButton = panel.make_input_local(event)
|
||||||
if !Rect2(Vector2.ZERO, panel.size).has_point(click.position):
|
if !Rect2(Vector2.ZERO, panel.size).has_point(click.position):
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ extends Panel
|
|||||||
## One character in the character select popup: its picture and name. Clicking it picks
|
## One character in the character select popup: its picture and name. Clicking it picks
|
||||||
## the character.
|
## 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)
|
signal new_character_selected(img_texture: ImageTexture, character_name: String)
|
||||||
|
|
||||||
|
## The character's name: its picture's file name, e.g. "PaperMario.png".
|
||||||
|
var character_name: String = ""
|
||||||
|
|
||||||
@onready var character_image: TextureRect = %CharacterTextureRect
|
@onready var character_image: TextureRect = %CharacterTextureRect
|
||||||
@onready var character_name_label: Label = %CharacterName
|
@onready var character_name_label: Label = %CharacterName
|
||||||
@@ -19,12 +22,13 @@ func _ready() -> void:
|
|||||||
## Shows [param character_name] and its picture. [param fetch_image] is called with the
|
## 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.
|
## name and a Callable that takes the picture as an [Image], e.g.
|
||||||
## [method CharacterCache.fetch_image].
|
## [method CharacterCache.fetch_image].
|
||||||
func set_character_name(character_name: String, fetch_image: Callable) -> void:
|
func set_character_name(new_name: String, fetch_image: Callable) -> void:
|
||||||
character_name_label.text = display_name(character_name)
|
character_name = new_name
|
||||||
|
character_name_label.text = display_name(new_name)
|
||||||
tooltip_text = character_name_label.text
|
tooltip_text = character_name_label.text
|
||||||
var image_fetched: Callable = func image_fetched(picture: Image) -> void:
|
var image_fetched: Callable = func image_fetched(picture: Image) -> void:
|
||||||
character_image.texture = ImageTexture.create_from_image(picture)
|
character_image.texture = ImageTexture.create_from_image(picture)
|
||||||
fetch_image.call(character_name, image_fetched)
|
fetch_image.call(new_name, image_fetched)
|
||||||
|
|
||||||
|
|
||||||
## The name to show for a picture's [param file_name]: without the file extension, and
|
## The name to show for a picture's [param file_name]: without the file extension, and
|
||||||
@@ -58,4 +62,4 @@ func _on_gui_input(event: InputEvent) -> void:
|
|||||||
&& event.button_index == MOUSE_BUTTON_LEFT && Settings.character_select_open:
|
&& event.button_index == MOUSE_BUTTON_LEFT && Settings.character_select_open:
|
||||||
accept_event()
|
accept_event()
|
||||||
GameLog.debug(character_name_label.text)
|
GameLog.debug(character_name_label.text)
|
||||||
new_character_selected.emit(character_image.texture)
|
new_character_selected.emit(character_image.texture, character_name)
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
class_name CharacterSelectPopup
|
class_name CharacterSelectPopup
|
||||||
extends BasePopup
|
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.
|
## Emitted with the picture and name of the character that was picked.
|
||||||
signal character_selected(texture: ImageTexture)
|
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 can_select_character: bool = false
|
||||||
var character: PackedScene = preload("res://popups/CharacterCard.tscn")
|
var character: PackedScene = preload("res://popups/CharacterCard.tscn")
|
||||||
## Keeps the pictures on disk, so only changed ones are downloaded.
|
## Keeps the pictures on disk, so only changed ones are downloaded.
|
||||||
var character_cache: CharacterCache = CharacterCache.new()
|
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 character_panel: Panel = %CharacterPanel
|
||||||
@onready var title_label: Label = %TitleLabel
|
@onready var title_label: Label = %TitleLabel
|
||||||
@@ -25,18 +36,30 @@ func _on_close_timeout() -> void:
|
|||||||
close_timer.stop()
|
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:
|
func load_characters() -> void:
|
||||||
GameLog.debug("load_characters")
|
GameLog.debug("load_characters")
|
||||||
|
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:
|
var list_loaded: Callable = func list_loaded(names: Array[String]) -> void:
|
||||||
GameLog.debug("list_loaded")
|
GameLog.debug("list_loaded")
|
||||||
|
# The source changed again before the server answered
|
||||||
|
if source != loading:
|
||||||
|
return
|
||||||
Settings.delete_children(character_grid)
|
Settings.delete_children(character_grid)
|
||||||
for character_name: String in names:
|
for character_name: String in names:
|
||||||
GameLog.debug("character_name ", character_name)
|
GameLog.debug("character_name ", character_name)
|
||||||
var character_box: CharacterCard = character.instantiate()
|
var character_box: CharacterCard = character.instantiate()
|
||||||
character_grid.add_child(character_box)
|
character_grid.add_child(character_box)
|
||||||
character_box.set_character_name(character_name, character_cache.fetch_image)
|
character_box.set_character_name(character_name, loading.fetch_image)
|
||||||
character_box.new_character_selected.connect(_on_character_selected)
|
character_box.new_character_selected.connect(_on_character_selected)
|
||||||
character_cache.load_list(list_loaded)
|
loading.load_list(list_loaded)
|
||||||
|
if changed:
|
||||||
|
source_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
func show_grid(player_name: String) -> void:
|
func show_grid(player_name: String) -> void:
|
||||||
@@ -53,12 +76,12 @@ func close() -> void:
|
|||||||
Settings.character_select_open = false
|
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")
|
GameLog.debug("_on_character_selected")
|
||||||
# Not close(): character_select_open stays true until close_timer fires, so the click
|
# 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
|
# that picked the character does not also select a song behind it
|
||||||
visible = false
|
visible = false
|
||||||
if can_select_character:
|
if can_select_character:
|
||||||
character_selected.emit(texture)
|
character_selected.emit(texture, character_name)
|
||||||
close_timer.start()
|
close_timer.start()
|
||||||
can_select_character = false
|
can_select_character = false
|
||||||
@@ -23,9 +23,38 @@ static func add_to(parent: Control, popups: Array[BasePopup]) -> PopupBackdrop:
|
|||||||
for popup: BasePopup in popups:
|
for popup: BasePopup in popups:
|
||||||
parent.move_child(popup, -1)
|
parent.move_child(popup, -1)
|
||||||
popup.visibility_changed.connect(backdrop._update)
|
popup.visibility_changed.connect(backdrop._update)
|
||||||
|
popup.closing.connect(backdrop._on_popup_closing)
|
||||||
|
popup.opening.connect(backdrop._on_popup_opening)
|
||||||
backdrop._update()
|
backdrop._update()
|
||||||
return backdrop
|
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:
|
func _update() -> void:
|
||||||
|
var was_visible: bool = visible
|
||||||
visible = _popups.any(func(popup: BasePopup) -> bool: return popup.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)
|
||||||
+49
-3
@@ -15,10 +15,23 @@ const MARGIN_BOTTOM: float = 44.0
|
|||||||
## About how many song numbers fit along the bottom.
|
## About how many song numbers fit along the bottom.
|
||||||
const MAX_SONG_LABELS: int = 20
|
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 _history: ScoreHistory
|
||||||
var _winning_score: int = 0
|
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
|
## 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.
|
## someone has more points. A winning score of 0 draws no winning line.
|
||||||
func show_history(history: ScoreHistory, winning_score: int) -> void:
|
func show_history(history: ScoreHistory, winning_score: int) -> void:
|
||||||
@@ -27,6 +40,32 @@ func show_history(history: ScoreHistory, winning_score: int) -> void:
|
|||||||
queue_redraw()
|
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.
|
## The colour of the player at [param index] in the player list.
|
||||||
func player_color(index: int) -> Color:
|
func player_color(index: int) -> Color:
|
||||||
return get_theme_color(StringName("player_%d" % (index % 7 + 1)), THEME_TYPE)
|
return get_theme_color(StringName("player_%d" % (index % 7 + 1)), THEME_TYPE)
|
||||||
@@ -57,13 +96,20 @@ func _draw() -> void:
|
|||||||
|
|
||||||
var line_width: int = get_theme_constant(&"line_width", THEME_TYPE)
|
var line_width: int = get_theme_constant(&"line_width", THEME_TYPE)
|
||||||
var dot_radius: int = get_theme_constant(&"dot_radius", 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():
|
for index: int in _history.players.size():
|
||||||
var color: Color = player_color(index)
|
var color: Color = player_color(index)
|
||||||
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
|
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
|
||||||
draw_polyline(line_points(index), color, line_width, true)
|
var points: PackedVector2Array = clip_line(line_points(index), limit_x)
|
||||||
|
if points.size() >= 2:
|
||||||
|
draw_polyline(points, color, line_width, true)
|
||||||
for song: int in range(1, scores.size()):
|
for song: int in range(1, scores.size()):
|
||||||
if scores[song] > scores[song - 1]:
|
var dot: Vector2 = _point(song, scores[song], index)
|
||||||
draw_circle(_point(song, scores[song], index), dot_radius, color)
|
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()
|
var label_positions: Array[Vector2] = score_label_positions()
|
||||||
for index: int in _history.players.size():
|
for index: int in _history.players.size():
|
||||||
draw_string(font, label_positions[index],
|
draw_string(font, label_positions[index],
|
||||||
|
|||||||
+43
-11
@@ -1,7 +1,11 @@
|
|||||||
class_name SearchPopup
|
class_name SearchPopup
|
||||||
extends BasePopup
|
extends BasePopup
|
||||||
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
|
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
|
||||||
## highlights the letters that matched.
|
## 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.
|
## Every soundtrack on the server, sorted by name.
|
||||||
var soundtracks: Array = []
|
var soundtracks: Array = []
|
||||||
@@ -10,6 +14,8 @@ var _matcher: SoundtrackSearch = SoundtrackSearch.new()
|
|||||||
# One row per soundtrack, in the order of soundtracks. Rows are reused between searches.
|
# One row per soundtrack, in the order of soundtracks. Rows are reused between searches.
|
||||||
var _rows: Array[PanelContainer] = []
|
var _rows: Array[PanelContainer] = []
|
||||||
var _index_of: Dictionary = {}
|
var _index_of: Dictionary = {}
|
||||||
|
# The other names of each soundtrack, by its real name
|
||||||
|
var _alternatives: Dictionary = {}
|
||||||
|
|
||||||
@onready var search_panel: Panel = %SearchPanel
|
@onready var search_panel: Panel = %SearchPanel
|
||||||
@onready var search_field: LineEdit = %SearchField
|
@onready var search_field: LineEdit = %SearchField
|
||||||
@@ -54,6 +60,9 @@ func search() -> void:
|
|||||||
var row: PanelContainer = _rows[shown[position]]
|
var row: PanelContainer = _rows[shown[position]]
|
||||||
if row.get_index() != position:
|
if row.get_index() != position:
|
||||||
search_results.move_child(row, 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.visible = true
|
||||||
row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven"
|
row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven"
|
||||||
_show_name(row, soundtracks[shown[position]], search_text)
|
_show_name(row, soundtracks[shown[position]], search_text)
|
||||||
@@ -83,19 +92,26 @@ func get_list_of_soundtracks() -> void:
|
|||||||
if Settings.is_offline():
|
if Settings.is_offline():
|
||||||
set_soundtracks(Settings.music_folder.soundtrack_names())
|
set_soundtracks(Settings.music_folder.soundtrack_names())
|
||||||
return
|
return
|
||||||
var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void:
|
var with_names: Callable = func with_names(list: Array[Soundtrack]) -> void:
|
||||||
if typeof(array) == TYPE_ARRAY:
|
var names: Array[String] = []
|
||||||
set_soundtracks(array)
|
var alternatives: Dictionary = {}
|
||||||
else:
|
for soundtrack: Soundtrack in list:
|
||||||
GameLog.warning("Unexpected data")
|
names.append(soundtrack.name)
|
||||||
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
|
alternatives[soundtrack.name] = soundtrack.alternative_names
|
||||||
|
names.sort_custom(func(a: String, b: String) -> bool:
|
||||||
|
return a.naturalnocasecmp_to(b) < 0)
|
||||||
|
set_soundtracks(names, alternatives)
|
||||||
|
soundtracks_loaded.emit(list)
|
||||||
|
MusicServerClient.fetch_soundtracks(with_names, _load_names_only)
|
||||||
|
|
||||||
|
|
||||||
## Replaces the searchable soundtracks and shows them, keeping the current search.
|
## Replaces the searchable soundtracks and shows them, keeping the current search.
|
||||||
func set_soundtracks(list: Array) -> void:
|
## [param alternatives] has the other names of a soundtrack, by its real name.
|
||||||
|
func set_soundtracks(list: Array, alternatives: Dictionary = {}) -> void:
|
||||||
soundtracks = []
|
soundtracks = []
|
||||||
soundtracks.append_array(list)
|
soundtracks.append_array(list)
|
||||||
_matcher.set_names(soundtracks)
|
_alternatives = alternatives
|
||||||
|
_matcher.set_names(soundtracks, alternatives)
|
||||||
_index_of.clear()
|
_index_of.clear()
|
||||||
for index: int in range(soundtracks.size() - 1, -1, -1):
|
for index: int in range(soundtracks.size() - 1, -1, -1):
|
||||||
_index_of[soundtracks[index]] = index
|
_index_of[soundtracks[index]] = index
|
||||||
@@ -142,9 +158,25 @@ func _make_row() -> PanelContainer:
|
|||||||
return row
|
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:
|
func _show_name(row: PanelContainer, soundtrack_name: String, search_text: String) -> void:
|
||||||
var text: String = highlighted_name(soundtrack_name, search_text,
|
var highlight: Color = get_theme_color(&"font_color", &"HighlightLarge")
|
||||||
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)
|
var label: RichTextLabel = row.get_child(0)
|
||||||
# Setting the same text again would make the label lay it out again for nothing
|
# Setting the same text again would make the label lay it out again for nothing
|
||||||
if label.text != text:
|
if label.text != text:
|
||||||
|
|||||||
+111
-9
@@ -1,12 +1,14 @@
|
|||||||
class_name SettingsPopup
|
class_name SettingsPopup
|
||||||
extends BasePopup
|
extends BasePopup
|
||||||
## The application settings: which MusicServer or music folder to play from, fullscreen,
|
## The application settings: which MusicServer or music folder to play from, the theme,
|
||||||
## how many songs to download ahead, whether to prefer less played soundtracks and whether
|
## fullscreen, fewer animations, how many songs to download ahead, how random songs are
|
||||||
## plays count in the statistics, and a button that opens the folder the settings are
|
## picked and whether plays count in the statistics, and a button that opens the folder
|
||||||
## kept in. Opened from the Settings button in the top bar.
|
## 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
|
## 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.
|
## 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].
|
## The quiz settings, such as the winning score, are in [QuizSettingsCard].
|
||||||
|
|
||||||
@@ -14,16 +16,23 @@ extends BasePopup
|
|||||||
## order is changed. A new game starts.
|
## order is changed. A new game starts.
|
||||||
signal server_changed
|
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.
|
## Space between the rows and the panel's edges.
|
||||||
const PADDING: Vector2 = Vector2(20, 12)
|
const PADDING: Vector2 = Vector2(20, 12)
|
||||||
|
|
||||||
@onready var settings_panel: Panel = %SettingsPanel
|
@onready var settings_panel: Panel = %SettingsPanel
|
||||||
@onready var select_server_button: OptionButton = %SelectServerButton
|
@onready var select_server_button: OptionButton = %SelectServerButton
|
||||||
@onready var fullscreen_button: CheckButton = %FullscreenButton
|
@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 cache_label: Label = %CacheLabel
|
||||||
@onready var lower_cache_button: Button = %LowerCacheButton
|
@onready var lower_cache_button: Button = %LowerCacheButton
|
||||||
@onready var increase_cache_button: Button = %IncreaseCacheButton
|
@onready var increase_cache_button: Button = %IncreaseCacheButton
|
||||||
@onready var low_played_button: CheckButton = %LowPlayedCheckButton
|
@onready var random_mode_button: OptionButton = %RandomModeButton
|
||||||
@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
|
@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
|
||||||
@onready var rows: VBoxContainer = %VBoxContainer
|
@onready var rows: VBoxContainer = %VBoxContainer
|
||||||
@onready var folder_label: Label = %FolderLabel
|
@onready var folder_label: Label = %FolderLabel
|
||||||
@@ -33,7 +42,14 @@ const PADDING: Vector2 = Vector2(20, 12)
|
|||||||
@onready var play_order_row: HBoxContainer = %PlayOrderRow
|
@onready var play_order_row: HBoxContainer = %PlayOrderRow
|
||||||
@onready var play_order_button: OptionButton = %PlayOrderButton
|
@onready var play_order_button: OptionButton = %PlayOrderButton
|
||||||
@onready var folder_dialog: FileDialog = %FolderDialog
|
@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 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
|
## Shows a folder in the system's file manager. The tests replace it, so no file manager
|
||||||
## opens while they run.
|
## opens while they run.
|
||||||
@@ -46,22 +62,32 @@ var _panel_middle_y: float = 0.0
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
for server: String in Settings.servers:
|
for server: String in Settings.servers:
|
||||||
select_server_button.add_item(server)
|
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
|
fullscreen_button.button_pressed = Settings.fullscreen
|
||||||
select_server_button.select(Settings.selected_server)
|
select_server_button.select(Settings.selected_server)
|
||||||
cache_label.text = str(Playlist.number_of_tracks_to_preload)
|
cache_label.text = str(Playlist.number_of_tracks_to_preload)
|
||||||
|
|
||||||
fullscreen_button.pressed.connect(fullscreen)
|
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)
|
choose_folder_button.pressed.connect(folder_dialog.popup_centered)
|
||||||
folder_dialog.dir_selected.connect(use_music_folder)
|
folder_dialog.dir_selected.connect(use_music_folder)
|
||||||
use_server_button.pressed.connect(use_server)
|
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.pressed.connect(open_config_folder)
|
||||||
open_config_folder_button.tooltip_text = Settings.config_dir()
|
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)
|
play_order_button.item_selected.connect(_on_play_order_selected)
|
||||||
lower_cache_button.pressed.connect(lower_cache)
|
lower_cache_button.pressed.connect(lower_cache)
|
||||||
increase_cache_button.pressed.connect(increase_cache)
|
increase_cache_button.pressed.connect(increase_cache)
|
||||||
select_server_button.item_selected.connect(select_server)
|
select_server_button.item_selected.connect(select_server)
|
||||||
low_played_button.toggled.connect(
|
random_mode_button.item_selected.connect(
|
||||||
func(on: bool) -> void: Settings.use_low_played_mode = on)
|
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)
|
add_to_database_button.toggled.connect(func(on: bool) -> void: Settings.add_to_stats = on)
|
||||||
visibility_changed.connect(_on_visibility_changed)
|
visibility_changed.connect(_on_visibility_changed)
|
||||||
# Alt+Enter switches fullscreen from the main window, which resizes the window
|
# Alt+Enter switches fullscreen from the main window, which resizes the window
|
||||||
@@ -70,6 +96,17 @@ func _ready() -> void:
|
|||||||
_show_music_source()
|
_show_music_source()
|
||||||
|
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
|
||||||
|
## 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:
|
func fullscreen() -> void:
|
||||||
Settings.set_fullscreen(!Settings.fullscreen)
|
Settings.set_fullscreen(!Settings.fullscreen)
|
||||||
|
|
||||||
@@ -104,6 +141,60 @@ func open_config_folder() -> void:
|
|||||||
show_folder.call(dir)
|
show_folder.call(dir)
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
|
||||||
|
## 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())
|
||||||
|
|
||||||
|
|
||||||
|
## [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))
|
||||||
|
|
||||||
|
|
||||||
|
## 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()
|
||||||
|
|
||||||
|
|
||||||
|
## 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:
|
func lower_cache() -> void:
|
||||||
Playlist.number_of_tracks_to_preload = maxi(Playlist.number_of_tracks_to_preload - 1, 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)
|
cache_label.text = str(Playlist.number_of_tracks_to_preload)
|
||||||
@@ -137,10 +228,12 @@ func _use_server(new_server: int) -> void:
|
|||||||
func _on_visibility_changed() -> void:
|
func _on_visibility_changed() -> void:
|
||||||
if visible:
|
if visible:
|
||||||
_update_fullscreen_button()
|
_update_fullscreen_button()
|
||||||
low_played_button.set_pressed_no_signal(Settings.use_low_played_mode)
|
show_cache_size()
|
||||||
|
random_mode_button.select(Settings.random_mode)
|
||||||
add_to_database_button.set_pressed_no_signal(Settings.add_to_stats)
|
add_to_database_button.set_pressed_no_signal(Settings.add_to_stats)
|
||||||
else:
|
else:
|
||||||
folder_error_label.visible = false
|
folder_error_label.visible = false
|
||||||
|
characters_error_label.visible = false
|
||||||
_fit_to_rows()
|
_fit_to_rows()
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +255,16 @@ func _show_music_source() -> void:
|
|||||||
folder_label.text = folder.display_name()
|
folder_label.text = folder.display_name()
|
||||||
folder_label.tooltip_text = folder.path
|
folder_label.tooltip_text = folder.path
|
||||||
play_order_row.visible = folder != null && folder.kind == MusicFolder.Kind.PREPARED_QUIZ
|
play_order_row.visible = folder != null && folder.kind == MusicFolder.Kind.PREPARED_QUIZ
|
||||||
for control: BaseButton in [select_server_button, low_played_button, add_to_database_button]:
|
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
|
control.disabled = folder != null
|
||||||
_fit_to_rows()
|
_fit_to_rows()
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,305 @@
|
|||||||
class_name SoundtrackStatsPopup
|
class_name SoundtrackStatsPopup
|
||||||
extends BasePopup
|
extends BasePopup
|
||||||
## Shows a soundtrack's play statistics and every song in it, most played first.
|
## Shows a soundtrack's cover, play statistics and alternative names, and every song in
|
||||||
## Opened by clicking a card in the statistics view.
|
## 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 title_label: Label = %TitleLabel
|
||||||
@onready var summary_label: Label = %SummaryLabel
|
@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 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 _ready() -> void:
|
||||||
func show_soundtrack(soundtrack: SoundtrackWithSongs) -> void:
|
edit_names_button.pressed.connect(edit_names)
|
||||||
title_label.text = soundtrack.soundtrack_name
|
add_name_button.pressed.connect(add_name)
|
||||||
var summary: PackedStringArray = [SoundtrackCard.played_text(soundtrack.soundtrack_played)]
|
new_name_field.text_submitted.connect(func(_text: String) -> void: add_name())
|
||||||
var last_played: String = SoundtrackCard.last_played_text(soundtrack.soundtrack_last_played)
|
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():
|
if !last_played.is_empty():
|
||||||
summary.append(last_played)
|
summary.append(last_played)
|
||||||
summary.append("%d %s" % [soundtrack.songs.size(),
|
summary.append("%d %s" % [shown.songs.size(),
|
||||||
"song" if soundtrack.songs.size() == 1 else "songs"])
|
"song" if shown.songs.size() == 1 else "songs"])
|
||||||
summary_label.text = " · ".join(summary)
|
summary_label.text = " · ".join(summary)
|
||||||
|
_show_names()
|
||||||
|
|
||||||
|
cover_placeholder.text = SoundtrackCard.initials(shown.soundtrack_name)
|
||||||
|
_load_cover()
|
||||||
|
|
||||||
Settings.delete_children(song_list)
|
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():
|
for row: int in songs.size():
|
||||||
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
|
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
|
||||||
&"TextLarge"))
|
&"TextLarge"))
|
||||||
open()
|
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 no_changes_label: Label = %NoChangesLabel
|
||||||
@onready var progress_hbox: HBoxContainer = %ProgressHBoxContainer
|
@onready var progress_hbox: HBoxContainer = %ProgressHBoxContainer
|
||||||
@onready var sync_hbox: HBoxContainer = %SyncHBoxContainer
|
@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_vbox: VBoxContainer = %SoundtracksAddedVBox
|
||||||
@onready var soundtracks_added_separator: VSeparator = %SoundtracksAddedSeparator
|
@onready var soundtracks_added_separator: VSeparator = %SoundtracksAddedSeparator
|
||||||
@onready var soundtracks_readded_vbox: VBoxContainer = %SoundtracksReAddedVBox
|
@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:
|
func start_sync() -> void:
|
||||||
GameLog.debug("start_sync")
|
GameLog.debug("start_sync")
|
||||||
visible = true
|
open()
|
||||||
if has_synced:
|
if has_synced:
|
||||||
# Already synced, just show the results
|
# Already synced, just show the results
|
||||||
show_sync_result()
|
show_sync_result()
|
||||||
@@ -77,13 +77,24 @@ func _on_sync_timeout() -> void:
|
|||||||
GameLog.debug("data_received: ", data_received)
|
GameLog.debug("data_received: ", data_received)
|
||||||
if data_received != null:
|
if data_received != null:
|
||||||
if data_received.has("progress"):
|
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 ""
|
sync_time.text = data_received.time_spent if data_received.has("time_spent") else ""
|
||||||
else:
|
else:
|
||||||
on_sync_finished(data_received)
|
_finish_progress(data_received)
|
||||||
MusicServerClient.fetch_sync_progress(get_progress)
|
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:
|
func on_sync_finished(data_received: Dictionary) -> void:
|
||||||
has_synced = true
|
has_synced = true
|
||||||
reset_sync_result()
|
reset_sync_result()
|
||||||
@@ -218,6 +229,7 @@ func show_sync_result() -> void:
|
|||||||
func show_sync_progress() -> void:
|
func show_sync_progress() -> void:
|
||||||
sync_title_label.text = "Sync in Progress"
|
sync_title_label.text = "Sync in Progress"
|
||||||
progress_hbox.visible = true
|
progress_hbox.visible = true
|
||||||
|
sync_progress_bar.wait()
|
||||||
no_changes_label.visible = false
|
no_changes_label.visible = false
|
||||||
sync_hbox.visible = false
|
sync_hbox.visible = false
|
||||||
caught_errors_vbox.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
|
||||||
+52
-1
@@ -2,7 +2,12 @@ class_name WinnerPopup
|
|||||||
extends BasePopup
|
extends BasePopup
|
||||||
## Shows who won, with their character, where the other players placed, and how the game
|
## 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.
|
## 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.
|
## Size of the coloured square in front of each name in the legend.
|
||||||
const LEGEND_SQUARE_SIZE: Vector2 = Vector2(14, 14)
|
const LEGEND_SQUARE_SIZE: Vector2 = Vector2(14, 14)
|
||||||
|
|
||||||
@@ -29,7 +34,7 @@ func show_winner(winner: PlayerObject) -> void:
|
|||||||
## Shows [param winners], who share the top score, with everyone else's placing below.
|
## 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.
|
## Tied players get the same placing. With no players, it says the quiz is over.
|
||||||
func show_winners(winners: Array[PlayerObject]) -> void:
|
func show_winners(winners: Array[PlayerObject]) -> void:
|
||||||
visible = true
|
open()
|
||||||
winner_label.text = winners_text(winners)
|
winner_label.text = winners_text(winners)
|
||||||
_show_pictures(winners)
|
_show_pictures(winners)
|
||||||
|
|
||||||
@@ -49,6 +54,7 @@ func show_winners(winners: Array[PlayerObject]) -> void:
|
|||||||
players_list.add_child(label)
|
players_list.add_child(label)
|
||||||
|
|
||||||
show_history(ScoreHistory.from_playlist(Settings.player_array))
|
show_history(ScoreHistory.from_playlist(Settings.player_array))
|
||||||
|
_celebrate()
|
||||||
|
|
||||||
|
|
||||||
## The headline for [param winners], e.g. "Crono won with 5 points!!" or "Crono and
|
## The headline for [param winners], e.g. "Crono won with 5 points!!" or "Crono and
|
||||||
@@ -80,6 +86,51 @@ static func placing(player: PlayerObject) -> int:
|
|||||||
return place
|
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
|
# One picture per winner, next to each other
|
||||||
func _show_pictures(winners: Array[PlayerObject]) -> void:
|
func _show_pictures(winners: Array[PlayerObject]) -> void:
|
||||||
var header: Node = winner_picture.get_parent()
|
var header: Node = winner_picture.get_parent()
|
||||||
|
|||||||
+38
-3
@@ -2,22 +2,57 @@
|
|||||||
|
|
||||||
[ext_resource type="Script" path="res://quiz/now_playing.gd" id="part_script"]
|
[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
|
layout_mode = 3
|
||||||
offset_right = 1088.0
|
offset_right = 1088.0
|
||||||
offset_bottom = 320.0
|
offset_bottom = 320.0
|
||||||
|
theme_override_constants/separation = 32
|
||||||
|
alignment = 1
|
||||||
script = ExtResource("part_script")
|
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)
|
custom_minimum_size = Vector2(1, 1)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_type_variation = &"TitleLarge"
|
theme_type_variation = &"TitleLarge"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
autowrap_mode = 2
|
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)
|
custom_minimum_size = Vector2(1, 1)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_type_variation = &"TitleMedium"
|
theme_type_variation = &"TitleMedium"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
autowrap_mode = 2
|
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://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"]
|
[ext_resource type="Texture2D" uid="uid://b2kj6m8qpsgb1" path="res://icons/person_remove_light.svg" id="3_j3uxe"]
|
||||||
|
|
||||||
[node name="PlayerRow" type="Control"]
|
[node name="PlayerRow" type="PanelContainer"]
|
||||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
custom_minimum_size = Vector2(0, 40)
|
||||||
layout_mode = 3
|
|
||||||
anchors_preset = 0
|
|
||||||
offset_bottom = 40.0
|
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
theme_type_variation = &"ListRowEven"
|
||||||
script = ExtResource("1_if4kc")
|
script = ExtResource("1_if4kc")
|
||||||
|
|
||||||
[node name="HBoxContainer" type="HBoxContainer" parent="."]
|
[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
|
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
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(130, 40)
|
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
size_flags_vertical = 4
|
||||||
size_flags_vertical = 1
|
theme_type_variation = &"PlayerCharacter"
|
||||||
text = ": 0"
|
|
||||||
vertical_alignment = 1
|
|
||||||
clip_text = true
|
|
||||||
text_overrun_behavior = 3
|
|
||||||
|
|
||||||
[node name="Panel3" type="Panel" parent="HBoxContainer"]
|
[node name="Character" type="TextureButton" parent="HBoxContainer/CharacterFrame"]
|
||||||
custom_minimum_size = Vector2(16, 2.08165e-12)
|
|
||||||
layout_mode = 2
|
|
||||||
theme_type_variation = &"DarkSpacer"
|
|
||||||
|
|
||||||
[node name="Character" type="TextureButton" parent="HBoxContainer"]
|
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(80, 40)
|
custom_minimum_size = Vector2(64, 32)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
tooltip_text = "Change character"
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
texture_normal = ExtResource("2_hpj3s")
|
texture_normal = ExtResource("2_hpj3s")
|
||||||
ignore_texture_size = true
|
ignore_texture_size = true
|
||||||
stretch_mode = 5
|
stretch_mode = 5
|
||||||
|
|
||||||
[node name="Panel2" type="Panel" parent="HBoxContainer"]
|
[node name="Name" type="Label" parent="HBoxContainer"]
|
||||||
custom_minimum_size = Vector2(30, 40)
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
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"]
|
[node name="AddPoint" type="Button" parent="HBoxContainer"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(40, 40)
|
custom_minimum_size = Vector2(40, 36)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "+1"
|
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"]
|
[node name="RemovePoint" type="Button" parent="HBoxContainer"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(40, 40)
|
custom_minimum_size = Vector2(40, 36)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "-1"
|
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"]
|
[node name="RemovePlayer" type="TextureButton" parent="HBoxContainer"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(40, 40)
|
visible = false
|
||||||
|
custom_minimum_size = Vector2(36, 36)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
tooltip_text = "Remove player"
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
texture_normal = ExtResource("3_j3uxe")
|
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="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="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"]
|
[sub_resource type="InputEventKey" id="InputEventKey_03bm3"]
|
||||||
device = -1
|
device = -1
|
||||||
@@ -17,6 +16,7 @@ events = [SubResource("InputEventKey_03bm3")]
|
|||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
offset_right = 641.0
|
offset_right = 641.0
|
||||||
offset_bottom = 401.0
|
offset_bottom = 401.0
|
||||||
|
theme_type_variation = &"PlayerList"
|
||||||
script = ExtResource("part_script")
|
script = ExtResource("part_script")
|
||||||
|
|
||||||
[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=270084211]
|
[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=270084211]
|
||||||
@@ -26,55 +26,63 @@ layout_mode = 2
|
|||||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
custom_minimum_size = Vector2(2.08165e-12, 40)
|
||||||
layout_mode = 2
|
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]
|
[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
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
size_flags_vertical = 1
|
size_flags_vertical = 1
|
||||||
|
theme_type_variation = &"TextLarge"
|
||||||
text = "Players"
|
text = "Players"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
vertical_alignment = 1
|
vertical_alignment = 1
|
||||||
|
|
||||||
[node name="AddPlayersButton" type="TextureButton" parent="VBoxContainer/HBoxContainer" unique_id=634410148]
|
[node name="AddPlayersButton" type="TextureButton" parent="VBoxContainer/HBoxContainer" unique_id=634410148]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
custom_minimum_size = Vector2(36, 36)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 8
|
size_flags_horizontal = 8
|
||||||
|
size_flags_vertical = 4
|
||||||
|
tooltip_text = "Add or remove players (Alt+A)"
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
shortcut = SubResource("Shortcut_jafqj")
|
shortcut = SubResource("Shortcut_jafqj")
|
||||||
texture_normal = ExtResource("5_31tjv")
|
texture_normal = ExtResource("5_31tjv")
|
||||||
|
ignore_texture_size = true
|
||||||
|
stretch_mode = 5
|
||||||
|
|
||||||
[node name="AddPlayerContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=1371948739]
|
[node name="AddPlayerContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=1371948739]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
visible = false
|
visible = false
|
||||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
custom_minimum_size = Vector2(0, 36)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
[node name="Panel" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=1569854056]
|
[node name="PlayerNameField" type="LineEdit" parent="VBoxContainer/AddPlayerContainer" unique_id=332535327]
|
||||||
layout_mode = 2
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
theme_type_variation = &"DarkSpacer"
|
|
||||||
|
|
||||||
[node name="PlayerNameField" type="TextEdit" parent="VBoxContainer/AddPlayerContainer" unique_id=332535327]
|
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
custom_minimum_size = Vector2(220, 0)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
|
||||||
placeholder_text = "Player name"
|
placeholder_text = "Player name"
|
||||||
script = ExtResource("7_qsdfy")
|
max_length = 10
|
||||||
|
|
||||||
[node name="AddPlayerButton" type="Button" parent="VBoxContainer/AddPlayerContainer" unique_id=245001313]
|
[node name="AddPlayerButton" type="Button" parent="VBoxContainer/AddPlayerContainer" unique_id=245001313]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
focus_mode = 0
|
||||||
text = "Add"
|
text = "Add"
|
||||||
|
|
||||||
[node name="Panel2" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=60215448]
|
[node name="AddPlayerHint" type="Label" parent="VBoxContainer/AddPlayerContainer"]
|
||||||
custom_minimum_size = Vector2(2.08165e-12, 40)
|
unique_name_in_owner = true
|
||||||
|
visible = false
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
theme_type_variation = &"HighlightMedium"
|
||||||
theme_type_variation = &"DarkSpacer"
|
text = "Up to 7 players"
|
||||||
|
vertical_alignment = 1
|
||||||
|
|
||||||
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer" unique_id=1000000001]
|
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer" unique_id=1000000001]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -84,5 +92,6 @@ horizontal_scroll_mode = 0
|
|||||||
[node name="PlayersListVBoxContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer" unique_id=1000000002]
|
[node name="PlayersListVBoxContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer" unique_id=1000000002]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 2
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
size_flags_vertical = 15
|
size_flags_vertical = 15
|
||||||
+19
-19
@@ -29,49 +29,49 @@ layout_mode = 0
|
|||||||
offset_left = 64.0
|
offset_left = 64.0
|
||||||
offset_top = 384.0
|
offset_top = 384.0
|
||||||
offset_right = 1152.0
|
offset_right = 1152.0
|
||||||
offset_bottom = 896.0
|
offset_bottom = 894.0
|
||||||
|
|
||||||
[node name="InspirationList" parent="." unique_id=498283761 instance=ExtResource("scene_InspirationList")]
|
[node name="InspirationList" parent="." unique_id=498283761 instance=ExtResource("scene_InspirationList")]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 1216.0
|
offset_left = 1216.0
|
||||||
offset_top = 128.0
|
offset_top = 128.0
|
||||||
offset_right = 1857.0
|
offset_right = 1856.0
|
||||||
offset_bottom = 512.0
|
offset_bottom = 512.0
|
||||||
|
|
||||||
[node name="NowPlaying" parent="." unique_id=42468498 instance=ExtResource("scene_NowPlaying")]
|
[node name="NowPlaying" parent="." unique_id=42468498 instance=ExtResource("scene_NowPlaying")]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 64.0
|
offset_left = 64.0
|
||||||
offset_top = 64.0
|
offset_top = 56.0
|
||||||
offset_right = 1152.0
|
offset_right = 1152.0
|
||||||
offset_bottom = 384.0
|
offset_bottom = 384.0
|
||||||
|
|
||||||
[node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")]
|
[node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 64.0
|
offset_left = 64.0
|
||||||
offset_top = 984.0
|
offset_top = 970.0
|
||||||
offset_right = 1856.0
|
offset_right = 1856.0
|
||||||
offset_bottom = 1026.0
|
offset_bottom = 1012.0
|
||||||
|
|
||||||
[node name="PlayersPanel" parent="." unique_id=1708375945 instance=ExtResource("scene_PlayersPanel")]
|
[node name="PlayersPanel" parent="." unique_id=1708375945 instance=ExtResource("scene_PlayersPanel")]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 1216.0
|
offset_left = 1216.0
|
||||||
offset_top = 576.0
|
offset_top = 544.0
|
||||||
offset_right = 1857.0
|
offset_right = 1856.0
|
||||||
offset_bottom = 977.0
|
offset_bottom = 954.0
|
||||||
|
|
||||||
[node name="QuizToolbar" parent="." unique_id=1873301941 instance=ExtResource("scene_QuizToolbar")]
|
[node name="QuizToolbar" parent="." unique_id=1873301941 instance=ExtResource("scene_QuizToolbar")]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 1360.0
|
offset_left = 1216.0
|
||||||
offset_top = 64.0
|
offset_top = 64.0
|
||||||
offset_right = 1855.0
|
offset_right = 1856.0
|
||||||
offset_bottom = 95.0
|
offset_bottom = 100.0
|
||||||
|
|
||||||
[node name="SongControls" parent="." unique_id=1640212773 instance=ExtResource("scene_SongControls")]
|
[node name="SongControls" parent="." unique_id=1640212773 instance=ExtResource("scene_SongControls")]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 65.0
|
offset_left = 64.0
|
||||||
offset_top = 944.0
|
offset_top = 918.0
|
||||||
offset_right = 368.0
|
offset_right = 704.0
|
||||||
offset_bottom = 975.0
|
offset_bottom = 954.0
|
||||||
|
|
||||||
[node name="CharacterSelectPopup" parent="." unique_id=1679856322 instance=ExtResource("6_dhrvg")]
|
[node name="CharacterSelectPopup" parent="." unique_id=1679856322 instance=ExtResource("6_dhrvg")]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
@@ -94,9 +94,9 @@ offset_bottom = 80.0
|
|||||||
[node name="QuizSettingsButton" type="Button" parent="." unique_id=1323246979]
|
[node name="QuizSettingsButton" type="Button" parent="." unique_id=1323246979]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 64.0
|
offset_left = 64.0
|
||||||
offset_top = 1030.0
|
offset_top = 1028.0
|
||||||
offset_right = 224.0
|
offset_right = 224.0
|
||||||
offset_bottom = 1066.0
|
offset_bottom = 1064.0
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "Quiz settings"
|
text = "Quiz settings"
|
||||||
@@ -107,9 +107,9 @@ expand_icon = true
|
|||||||
visible = false
|
visible = false
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 64.0
|
offset_left = 64.0
|
||||||
offset_top = 984.0
|
offset_top = 962.0
|
||||||
offset_right = 64.0
|
offset_right = 64.0
|
||||||
offset_bottom = 984.0
|
offset_bottom = 962.0
|
||||||
|
|
||||||
[node name="SongLoader" type="Node" parent="." unique_id=1520867349]
|
[node name="SongLoader" type="Node" parent="." unique_id=1520867349]
|
||||||
script = ExtResource("song_loader_script")
|
script = ExtResource("song_loader_script")
|
||||||
@@ -50,6 +50,49 @@ focus_mode = 0
|
|||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "Hide next track"
|
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"]
|
[node name="HideBeginningCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -80,6 +123,7 @@ custom_minimum_size = Vector2(30, 0)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "-1"
|
text = "-1"
|
||||||
|
|
||||||
[node name="IncreaseHideForSecondsButton" type="Button" parent="CardPanel/VBox/HideForSecondsRow"]
|
[node name="IncreaseHideForSecondsButton" type="Button" parent="CardPanel/VBox/HideForSecondsRow"]
|
||||||
@@ -88,6 +132,7 @@ custom_minimum_size = Vector2(30, 0)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "+1"
|
text = "+1"
|
||||||
|
|
||||||
[node name="HideLengthCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
[node name="HideLengthCheckButton" type="CheckButton" parent="CardPanel/VBox"]
|
||||||
@@ -127,6 +172,7 @@ custom_minimum_size = Vector2(30, 0)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "-1"
|
text = "-1"
|
||||||
|
|
||||||
[node name="IncreaseButton" type="Button" parent="CardPanel/VBox/WinningScoreRow"]
|
[node name="IncreaseButton" type="Button" parent="CardPanel/VBox/WinningScoreRow"]
|
||||||
@@ -135,6 +181,7 @@ custom_minimum_size = Vector2(30, 0)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "+1"
|
text = "+1"
|
||||||
|
|
||||||
[node name="InspirationRow" type="HBoxContainer" parent="CardPanel/VBox"]
|
[node name="InspirationRow" type="HBoxContainer" parent="CardPanel/VBox"]
|
||||||
@@ -159,6 +206,7 @@ custom_minimum_size = Vector2(30, 0)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "-1"
|
text = "-1"
|
||||||
|
|
||||||
[node name="IncreaseInspirationSpeedButton" type="Button" parent="CardPanel/VBox/InspirationRow"]
|
[node name="IncreaseInspirationSpeedButton" type="Button" parent="CardPanel/VBox/InspirationRow"]
|
||||||
@@ -167,4 +215,5 @@ custom_minimum_size = Vector2(30, 0)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
|
theme_type_variation = &"StepButton"
|
||||||
text = "+1"
|
text = "+1"
|
||||||
+7
-18
@@ -10,45 +10,34 @@ keycode = 83
|
|||||||
[sub_resource type="Shortcut" id="Shortcut_search"]
|
[sub_resource type="Shortcut" id="Shortcut_search"]
|
||||||
events = [SubResource("InputEventKey_search")]
|
events = [SubResource("InputEventKey_search")]
|
||||||
|
|
||||||
[node name="QuizToolbar" type="Control" unique_id=1873301941]
|
[node name="QuizToolbar" type="HBoxContainer" unique_id=1873301941]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
offset_right = 495.0
|
|
||||||
offset_bottom = 31.0
|
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
alignment = 2
|
||||||
script = ExtResource("part_script")
|
script = ExtResource("part_script")
|
||||||
|
|
||||||
[node name="SearchButton" type="Button" parent="." unique_id=1782453581]
|
[node name="SearchButton" type="Button" parent="." unique_id=1782453581]
|
||||||
layout_mode = 0
|
layout_mode = 2
|
||||||
offset_right = 111.0
|
|
||||||
offset_bottom = 31.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
shortcut = SubResource("Shortcut_search")
|
shortcut = SubResource("Shortcut_search")
|
||||||
text = "Search"
|
text = "Search"
|
||||||
|
|
||||||
[node name="ResetPlaylistButton" type="Button" parent="." unique_id=1754155620]
|
[node name="ResetPlaylistButton" type="Button" parent="." unique_id=1754155620]
|
||||||
layout_mode = 0
|
layout_mode = 2
|
||||||
offset_left = 128.0
|
|
||||||
offset_right = 239.0
|
|
||||||
offset_bottom = 31.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "Reset playlist"
|
text = "Reset playlist"
|
||||||
|
|
||||||
[node name="ResetPointsButton" type="Button" parent="." unique_id=942880776]
|
[node name="ResetPointsButton" type="Button" parent="." unique_id=942880776]
|
||||||
layout_mode = 0
|
layout_mode = 2
|
||||||
offset_left = 256.0
|
|
||||||
offset_right = 367.0
|
|
||||||
offset_bottom = 31.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "Reset points"
|
text = "Reset points"
|
||||||
|
|
||||||
[node name="SoundTestButton" type="Button" parent="." unique_id=1332971879]
|
[node name="SoundTestButton" type="Button" parent="." unique_id=1332971879]
|
||||||
layout_mode = 0
|
layout_mode = 2
|
||||||
offset_left = 384.0
|
|
||||||
offset_right = 495.0
|
|
||||||
offset_bottom = 31.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
text = "Sound test"
|
text = "Sound test"
|
||||||
+6
-11
@@ -20,29 +20,24 @@ unicode = 231
|
|||||||
[sub_resource type="Shortcut" id="Shortcut_d6fml"]
|
[sub_resource type="Shortcut" id="Shortcut_d6fml"]
|
||||||
events = [SubResource("InputEventKey_7dnqw")]
|
events = [SubResource("InputEventKey_7dnqw")]
|
||||||
|
|
||||||
[node name="SongControls" type="Control" unique_id=1640212773]
|
[node name="SongControls" type="HBoxContainer" unique_id=1640212773]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
offset_right = 303.0
|
|
||||||
offset_bottom = 31.0
|
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
alignment = 0
|
||||||
script = ExtResource("part_script")
|
script = ExtResource("part_script")
|
||||||
|
|
||||||
[node name="ShowAnswerButton" type="Button" parent="." unique_id=415388039]
|
[node name="ShowAnswerButton" type="Button" parent="." unique_id=415388039]
|
||||||
layout_mode = 0
|
layout_mode = 2
|
||||||
offset_right = 111.0
|
|
||||||
offset_bottom = 31.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
shortcut = SubResource("Shortcut_a7fvb")
|
shortcut = SubResource("Shortcut_a7fvb")
|
||||||
text = "Show answer"
|
text = "Show answer"
|
||||||
|
|
||||||
[node name="NextButton" type="Button" parent="." unique_id=1744703488]
|
[node name="NextButton" type="Button" parent="." unique_id=1744703488]
|
||||||
layout_mode = 0
|
layout_mode = 2
|
||||||
offset_left = 128.0
|
|
||||||
offset_right = 303.0
|
|
||||||
offset_bottom = 31.0
|
|
||||||
focus_mode = 0
|
focus_mode = 0
|
||||||
disabled = true
|
disabled = true
|
||||||
action_mode = 0
|
action_mode = 0
|
||||||
shortcut = SubResource("Shortcut_d6fml")
|
shortcut = SubResource("Shortcut_d6fml")
|
||||||
text = "Randomize new track"
|
text = "Next song"
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
class_name MusicListPanel
|
class_name MusicListPanel
|
||||||
extends PanelContainer
|
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.
|
## Emitted when the row of song number [param song_no] is clicked.
|
||||||
|
|
||||||
signal song_clicked(song_no: int)
|
signal song_clicked(song_no: int)
|
||||||
|
|
||||||
var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
|
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
|
@onready var _rows: VBoxContainer = %MusicList
|
||||||
|
|
||||||
@@ -15,6 +21,7 @@ var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
|
|||||||
func refresh() -> void:
|
func refresh() -> void:
|
||||||
Settings.delete_children(_rows)
|
Settings.delete_children(_rows)
|
||||||
var songs: Array[SongObject] = Playlist.get_playlist()
|
var songs: Array[SongObject] = Playlist.get_playlist()
|
||||||
|
var state: Dictionary = {}
|
||||||
for i: int in songs.size():
|
for i: int in songs.size():
|
||||||
var s: SongObject = songs[i]
|
var s: SongObject = songs[i]
|
||||||
var new_song_list_object: SongRow = _song_list_object.instantiate()
|
var new_song_list_object: SongRow = _song_list_object.instantiate()
|
||||||
@@ -22,6 +29,17 @@ func refresh() -> void:
|
|||||||
new_song_list_object.index = i
|
new_song_list_object.index = i
|
||||||
new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number))
|
new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number))
|
||||||
_rows.add_child(new_song_list_object)
|
_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:
|
func _on_row_input(event: InputEvent, song_no: int) -> void:
|
||||||
|
|||||||
+101
-6
@@ -1,10 +1,105 @@
|
|||||||
class_name NowPlaying
|
class_name NowPlaying
|
||||||
extends VBoxContainer
|
extends HBoxContainer
|
||||||
## The soundtrack and song that are playing, in large text at the top of the quiz.
|
## 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
|
## Shows [param soundtrack_title] and [param song_title]. The playlist hides them as
|
||||||
## question marks until the answer is shown.
|
## question marks until the answer is shown, and only then gives the
|
||||||
func show_song(soundtrack_title: String, song_title: String) -> void:
|
## [param soundtrack_uuid] whose cover is shown.
|
||||||
$SoundtrackLabel.text = soundtrack_title
|
func show_song(soundtrack_title: String, song_title: String,
|
||||||
$SongLabel.text = song_title
|
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
|
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 change_character_clicked
|
||||||
signal player_removed
|
signal player_removed
|
||||||
@@ -12,6 +17,9 @@ var player_object: PlayerObject
|
|||||||
var list_index: int
|
var list_index: int
|
||||||
|
|
||||||
@onready var player_name_field: Label = %Name
|
@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 add_button: Button = %AddPoint
|
||||||
@onready var minus_button: Button = %RemovePoint
|
@onready var minus_button: Button = %RemovePoint
|
||||||
@onready var character: TextureButton = %Character
|
@onready var character: TextureButton = %Character
|
||||||
@@ -25,12 +33,23 @@ func _ready() -> void:
|
|||||||
minus_button.set_shortcut(create_button_shortcut(list_index, true))
|
minus_button.set_shortcut(create_button_shortcut(list_index, true))
|
||||||
character.pressed.connect(change_character)
|
character.pressed.connect(change_character)
|
||||||
remove_player.pressed.connect(func() -> void: player_removed.emit())
|
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()
|
update_score()
|
||||||
set_player_character()
|
set_player_character()
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
## Shows the button that removes the player while the player list is being edited.
|
||||||
remove_player.visible = Settings.edit_players
|
func set_editing(editing: bool) -> void:
|
||||||
|
remove_player.visible = editing
|
||||||
|
|
||||||
|
|
||||||
func create_button_shortcut(index: int, ctrl_pressed: bool) -> Shortcut:
|
func create_button_shortcut(index: int, ctrl_pressed: bool) -> Shortcut:
|
||||||
@@ -57,22 +76,50 @@ func change_character() -> void:
|
|||||||
change_character_clicked.emit()
|
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")
|
GameLog.debug("Back in player list with new")
|
||||||
player_object.character = character_texture
|
player_object.set_character(character_texture, character_name)
|
||||||
set_player_character()
|
set_player_character()
|
||||||
|
|
||||||
|
|
||||||
func set_player_character() -> void:
|
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
|
character.texture_normal = player_object.character
|
||||||
|
|
||||||
|
|
||||||
func update_score() -> void:
|
func update_score() -> void:
|
||||||
if OS.has_feature("debug"):
|
score_label.text = str(player_object.player_score)
|
||||||
player_name_field.text = "%d %s: %d" % [list_index + 1, player_object.player_name,
|
_show_match_point(is_match_point())
|
||||||
player_object.player_score]
|
|
||||||
else:
|
|
||||||
player_name_field.text = player_object.player_name + ": " + str(player_object.player_score)
|
## Whether the player is one point from winning. A prepared quiz has no winning score.
|
||||||
|
func is_match_point() -> bool:
|
||||||
|
return !Settings.is_prepared_quiz() && Settings.winning_score > 1 \
|
||||||
|
&& player_object.player_score == Settings.winning_score - 1
|
||||||
|
|
||||||
|
|
||||||
|
func _on_score_changed() -> void:
|
||||||
|
Anim.pop(score_label)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_first_point() -> void:
|
||||||
|
Anim.flash(self, get_theme_color(&"first_point", &"Palette"), Anim.SLOW * 2)
|
||||||
|
Anim.pop(score_label, 1.8, Anim.SLOW)
|
||||||
|
|
||||||
|
|
||||||
|
# The glow and the tag show while the player is on match point
|
||||||
|
func _show_match_point(on: bool) -> void:
|
||||||
|
var glow: Node = get_node_or_null("Pulse")
|
||||||
|
if on == match_point_tag.visible && on == (glow != null):
|
||||||
|
return
|
||||||
|
match_point_tag.visible = on
|
||||||
|
if glow != null:
|
||||||
|
glow.queue_free()
|
||||||
|
glow.name = "PulseEnding"
|
||||||
|
if on:
|
||||||
|
Anim.pulse(self, get_theme_color(&"match_point", &"Palette"))
|
||||||
|
Anim.pop(match_point_tag, 1.4, Anim.SLOW)
|
||||||
|
|
||||||
|
|
||||||
|
func _set_character_hovered(hovered: bool) -> void:
|
||||||
|
character_frame.theme_type_variation = &"PlayerCharacterHover" if hovered \
|
||||||
|
else &"PlayerCharacter"
|
||||||
+35
-8
@@ -21,37 +21,54 @@ var _player_row: PackedScene = preload("res://quiz/PlayerRow.tscn")
|
|||||||
@onready var add_players_button: TextureButton = %AddPlayersButton
|
@onready var add_players_button: TextureButton = %AddPlayersButton
|
||||||
@onready var add_player_container: HBoxContainer = %AddPlayerContainer
|
@onready var add_player_container: HBoxContainer = %AddPlayerContainer
|
||||||
@onready var add_player_button: Button = %AddPlayerButton
|
@onready var add_player_button: Button = %AddPlayerButton
|
||||||
@onready var new_player_name_field: PlayerNameField = %PlayerNameField
|
@onready var add_player_hint: Label = %AddPlayerHint
|
||||||
|
@onready var new_player_name_field: LineEdit = %PlayerNameField
|
||||||
@onready var players_list_container: VBoxContainer = %PlayersListVBoxContainer
|
@onready var players_list_container: VBoxContainer = %PlayersListVBoxContainer
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_players_button.pressed.connect(add_players)
|
add_players_button.pressed.connect(add_players)
|
||||||
add_player_button.pressed.connect(add_player)
|
add_player_button.pressed.connect(add_player)
|
||||||
new_player_name_field.enter_key_pressed.connect(add_player)
|
new_player_name_field.text_submitted.connect(func(_text: String) -> void: add_player())
|
||||||
|
|
||||||
|
|
||||||
## Shows or hides the row for adding players, and the buttons for removing them.
|
## Shows or hides the row for adding players, and the buttons for removing them.
|
||||||
func add_players() -> void:
|
func add_players() -> void:
|
||||||
GameLog.debug("add_players")
|
GameLog.debug("add_players")
|
||||||
Settings.edit_players = !Settings.edit_players
|
Settings.edit_players = !Settings.edit_players
|
||||||
add_player_container.visible = !add_player_container.visible
|
add_player_container.visible = Settings.edit_players
|
||||||
|
for row: PlayerRow in players_list_container.get_children():
|
||||||
|
row.set_editing(Settings.edit_players)
|
||||||
|
_update_add_row()
|
||||||
|
if Settings.edit_players:
|
||||||
new_player_name_field.grab_focus()
|
new_player_name_field.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
## Adds a player with the name in the name field, unless it is empty or there are
|
## Adds a player with the name in the name field. Blank names, names another player
|
||||||
## already as many players as there can be.
|
## already has, and players beyond [constant Settings.MAX_NUMBER_OF_PLAYERS] are not
|
||||||
|
## added.
|
||||||
func add_player() -> void:
|
func add_player() -> void:
|
||||||
GameLog.debug("add_player")
|
GameLog.debug("add_player")
|
||||||
if new_player_name_field.text != "" \
|
var player_name: String = new_player_name_field.text.strip_edges()
|
||||||
&& Settings.player_array.size() < Settings.MAX_NUMBER_OF_PLAYERS:
|
if !can_add(player_name):
|
||||||
var new_player_object: PlayerObject = PlayerObject.new(new_player_name_field.text)
|
return
|
||||||
|
var new_player_object: PlayerObject = PlayerObject.new(player_name)
|
||||||
new_player_name_field.text = ""
|
new_player_name_field.text = ""
|
||||||
Settings.player_array.append(new_player_object)
|
Settings.player_array.append(new_player_object)
|
||||||
player_added.emit(new_player_object)
|
player_added.emit(new_player_object)
|
||||||
reload()
|
reload()
|
||||||
|
|
||||||
|
|
||||||
|
## Whether a player called [param player_name] can be added.
|
||||||
|
func can_add(player_name: String) -> bool:
|
||||||
|
if player_name.is_empty() || Settings.player_array.size() >= Settings.MAX_NUMBER_OF_PLAYERS:
|
||||||
|
return false
|
||||||
|
for player: PlayerObject in Settings.player_array:
|
||||||
|
if player.player_name.to_lower() == player_name.to_lower():
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
## Shows the players in [code]Settings.player_array[/code] again, e.g. after their
|
## Shows the players in [code]Settings.player_array[/code] again, e.g. after their
|
||||||
## points were reset.
|
## points were reset.
|
||||||
func reload() -> void:
|
func reload() -> void:
|
||||||
@@ -71,6 +88,16 @@ func reload() -> void:
|
|||||||
|
|
||||||
players_list_container.add_child(new_player)
|
players_list_container.add_child(new_player)
|
||||||
counter += 1
|
counter += 1
|
||||||
|
_update_add_row()
|
||||||
|
|
||||||
|
|
||||||
|
# A full list can't take more players, so the name field gives way to a note saying so
|
||||||
|
func _update_add_row() -> void:
|
||||||
|
var full: bool = Settings.player_array.size() >= Settings.MAX_NUMBER_OF_PLAYERS
|
||||||
|
add_player_hint.text = "Up to %d players" % Settings.MAX_NUMBER_OF_PLAYERS
|
||||||
|
add_player_hint.visible = full
|
||||||
|
new_player_name_field.visible = !full
|
||||||
|
add_player_button.visible = !full
|
||||||
|
|
||||||
|
|
||||||
func _on_change_character_clicked(row: PlayerRow, player_name: String) -> void:
|
func _on_change_character_clicked(row: PlayerRow, player_name: String) -> void:
|
||||||
|
|||||||
+132
-5
@@ -6,6 +6,9 @@ extends Control
|
|||||||
## The main window shows the quiz below its top bar. Pictures and buttons keep the
|
## The main window shows the quiz below its top bar. Pictures and buttons keep the
|
||||||
## positions they had in the main window, so the quiz fills the whole window and lets
|
## positions they had in the main window, so the quiz fills the whole window and lets
|
||||||
## clicks through where it has nothing.
|
## clicks through where it has nothing.
|
||||||
|
##
|
||||||
|
## The quiz is saved after every change and continued when the game starts again, see
|
||||||
|
## [QuizSave].
|
||||||
|
|
||||||
## Emitted when another MusicServer is picked in the settings, after the quiz has
|
## Emitted when another MusicServer is picked in the settings, after the quiz has
|
||||||
## reloaded from it.
|
## reloaded from it.
|
||||||
@@ -26,6 +29,11 @@ var stats_paused_by_win: bool = false
|
|||||||
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
|
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
|
||||||
var popup_backdrop: PopupBackdrop
|
var popup_backdrop: PopupBackdrop
|
||||||
|
|
||||||
|
# True from start until the saved quiz's songs are back, so a half restored quiz is
|
||||||
|
# never saved over the whole one
|
||||||
|
var _restoring: bool = false
|
||||||
|
var _save_queued: bool = false
|
||||||
|
|
||||||
@onready var inspiration_list: InspirationList = $InspirationList
|
@onready var inspiration_list: InspirationList = $InspirationList
|
||||||
@onready var now_playing: NowPlaying = $NowPlaying
|
@onready var now_playing: NowPlaying = $NowPlaying
|
||||||
@onready var players_panel: PlayersPanel = $PlayersPanel
|
@onready var players_panel: PlayersPanel = $PlayersPanel
|
||||||
@@ -50,7 +58,7 @@ func _ready() -> void:
|
|||||||
quiz_settings_button.pressed.connect(quiz_settings_card.toggle)
|
quiz_settings_button.pressed.connect(quiz_settings_card.toggle)
|
||||||
toolbar.reset_playlist_pressed.connect(reset_playlist)
|
toolbar.reset_playlist_pressed.connect(reset_playlist)
|
||||||
toolbar.reset_points_pressed.connect(reset_points)
|
toolbar.reset_points_pressed.connect(reset_points)
|
||||||
song_loader.song_list_loaded.connect(update_song_list)
|
song_loader.song_list_loaded.connect(_on_song_list_loaded)
|
||||||
song_loader.song_added.connect(_on_song_added)
|
song_loader.song_added.connect(_on_song_added)
|
||||||
|
|
||||||
character_select_popup.character_selected.connect(_on_new_character_selected)
|
character_select_popup.character_selected.connect(_on_new_character_selected)
|
||||||
@@ -60,10 +68,15 @@ func _ready() -> void:
|
|||||||
players_panel.change_character_requested.connect(_on_change_character_requested)
|
players_panel.change_character_requested.connect(_on_change_character_requested)
|
||||||
music_player.play_next_song.connect(play_next_song)
|
music_player.play_next_song.connect(play_next_song)
|
||||||
music_player.update_song_list.connect(update_song_list)
|
music_player.update_song_list.connect(update_song_list)
|
||||||
|
character_select_popup.source_changed.connect(_on_character_source_changed)
|
||||||
|
song_loader.saved_songs_dropped.connect(_on_saved_songs_dropped)
|
||||||
|
|
||||||
|
var saved: QuizSave = QuizSave.read()
|
||||||
|
_continue_saved_quiz(saved)
|
||||||
inspiration_list.load_soundtracks()
|
inspiration_list.load_soundtracks()
|
||||||
song_loader.load_song_list()
|
song_loader.load_song_list(saved)
|
||||||
character_select_popup.load_characters()
|
character_select_popup.load_characters()
|
||||||
|
_load_saved_characters(saved)
|
||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
@@ -151,14 +164,124 @@ func _on_song_added() -> void:
|
|||||||
song_controls.set_next_enabled(true)
|
song_controls.set_next_enabled(true)
|
||||||
if Settings.is_debug:
|
if Settings.is_debug:
|
||||||
update_song_list()
|
update_song_list()
|
||||||
|
save_soon()
|
||||||
|
|
||||||
|
|
||||||
func update_song_list() -> void:
|
func update_song_list() -> void:
|
||||||
GameLog.debug("update_song_list")
|
GameLog.debug("update_song_list")
|
||||||
if Playlist.currently_playing_song >= 0:
|
if Playlist.currently_playing_song >= 0:
|
||||||
now_playing.show_song(Playlist.get_current_soundtrack_title(),
|
now_playing.show_song(Playlist.get_current_soundtrack_title(),
|
||||||
Playlist.get_current_song_title())
|
Playlist.get_current_song_title(), Playlist.get_current_soundtrack_uuid())
|
||||||
music_list_panel.refresh()
|
music_list_panel.refresh()
|
||||||
|
save_soon()
|
||||||
|
|
||||||
|
|
||||||
|
## Saves the quiz at the end of this frame, once however many changes it had.
|
||||||
|
func save_soon() -> void:
|
||||||
|
if _restoring || _save_queued:
|
||||||
|
return
|
||||||
|
_save_queued = true
|
||||||
|
save_now.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
## Saves the quiz at once, e.g. before the game restarts with a new version.
|
||||||
|
func save_now() -> void:
|
||||||
|
_save_queued = false
|
||||||
|
if is_inside_tree():
|
||||||
|
QuizSave.capture(stats_paused_by_win).write()
|
||||||
|
|
||||||
|
|
||||||
|
# Puts back the saved quiz's music folder, players and statistics. Its songs come back
|
||||||
|
# with the song list, see SongLoader.load_song_list(), and its characters' pictures once
|
||||||
|
# the characters are loaded.
|
||||||
|
func _continue_saved_quiz(saved: QuizSave) -> void:
|
||||||
|
if saved == null:
|
||||||
|
Playlist.clear_song_files()
|
||||||
|
return
|
||||||
|
_restoring = true
|
||||||
|
if !saved.music_folder_path.is_empty():
|
||||||
|
var folder: MusicFolder = MusicFolder.scan(saved.music_folder_path)
|
||||||
|
if folder.kind == MusicFolder.Kind.EMPTY:
|
||||||
|
GameLog.warning("The saved quiz's music folder is gone: ", saved.music_folder_path)
|
||||||
|
saved.drop_songs()
|
||||||
|
else:
|
||||||
|
folder.restore_progress(saved.music_folder_progress)
|
||||||
|
Settings.music_folder = folder
|
||||||
|
elif saved.server != Settings.default_path:
|
||||||
|
# Another server has another playlist
|
||||||
|
saved.drop_songs()
|
||||||
|
Playlist.clear_song_files(saved.song_files())
|
||||||
|
for player: PlayerObject in saved.players:
|
||||||
|
Settings.player_array.append(player)
|
||||||
|
_on_player_added(player)
|
||||||
|
if saved.characters_dir != Settings.characters_dir():
|
||||||
|
for player: PlayerObject in saved.players:
|
||||||
|
player.clear_character()
|
||||||
|
players_panel.reload()
|
||||||
|
if saved.stats_paused_by_win:
|
||||||
|
stats_paused_by_win = true
|
||||||
|
Settings.add_to_stats = false
|
||||||
|
|
||||||
|
|
||||||
|
# The players' characters are saved by name; their pictures come from the characters
|
||||||
|
func _load_saved_characters(saved: QuizSave) -> void:
|
||||||
|
if saved == null:
|
||||||
|
return
|
||||||
|
for player: PlayerObject in saved.players:
|
||||||
|
var character_name: String = player.character_name
|
||||||
|
if character_name.is_empty():
|
||||||
|
continue
|
||||||
|
character_select_popup.source.fetch_image(character_name, func(picture: Image) -> void:
|
||||||
|
# Another character may have been picked meanwhile
|
||||||
|
if player.character_name == character_name:
|
||||||
|
player.character = ImageTexture.create_from_image(picture)
|
||||||
|
players_panel.reload())
|
||||||
|
|
||||||
|
|
||||||
|
func _on_song_list_loaded() -> void:
|
||||||
|
if _restoring:
|
||||||
|
_finish_restoring()
|
||||||
|
update_song_list()
|
||||||
|
|
||||||
|
|
||||||
|
# The saved songs are back: the song that was playing is ready to play again, and songs
|
||||||
|
# whose files are gone are downloaded again
|
||||||
|
func _finish_restoring() -> void:
|
||||||
|
_restoring = false
|
||||||
|
if !Settings.is_offline():
|
||||||
|
for i: int in Playlist.song_object_array.size():
|
||||||
|
var song_object: SongObject = Playlist.song_object_array[i]
|
||||||
|
var needed: bool = !song_object.has_played || i == Playlist.currently_playing_song
|
||||||
|
if needed && Playlist.get_song(i) == null:
|
||||||
|
MusicServerClient.fetch_song(song_object.song_number,
|
||||||
|
_on_saved_song_downloaded.bind(i))
|
||||||
|
if Playlist.currently_playing_song >= 0:
|
||||||
|
music_player.load_song_object(Playlist.currently_playing_song)
|
||||||
|
if Playlist.get_preloaded_songs() > 0:
|
||||||
|
song_controls.set_next_enabled(true)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_saved_song_downloaded(body: PackedByteArray, song_no: int) -> void:
|
||||||
|
if song_no >= Playlist.song_object_array.size():
|
||||||
|
return
|
||||||
|
Playlist.add_song_to_song_object(song_no, body)
|
||||||
|
if song_no == Playlist.currently_playing_song && !music_player.audio_player.playing:
|
||||||
|
music_player.load_song_object(song_no)
|
||||||
|
save_soon()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_saved_songs_dropped() -> void:
|
||||||
|
for player: PlayerObject in Settings.player_array:
|
||||||
|
player.reset_points()
|
||||||
|
players_panel.reload()
|
||||||
|
|
||||||
|
|
||||||
|
# Characters picked before are not among the new ones, so everyone picks again
|
||||||
|
func _on_character_source_changed() -> void:
|
||||||
|
for player: PlayerObject in Settings.player_array:
|
||||||
|
player.clear_character()
|
||||||
|
players_panel.reload()
|
||||||
|
save_soon()
|
||||||
|
|
||||||
|
|
||||||
func song_object_clicked(song_no: int) -> void:
|
func song_object_clicked(song_no: int) -> void:
|
||||||
@@ -204,6 +327,7 @@ func reset_points() -> void:
|
|||||||
if stats_paused_by_win:
|
if stats_paused_by_win:
|
||||||
Settings.add_to_stats = true
|
Settings.add_to_stats = true
|
||||||
stats_paused_by_win = false
|
stats_paused_by_win = false
|
||||||
|
save_soon()
|
||||||
|
|
||||||
|
|
||||||
# Each new player's points reach the music player, the log and the winner popup.
|
# Each new player's points reach the music player, the log and the winner popup.
|
||||||
@@ -214,6 +338,7 @@ func _on_player_added(new_player_object: PlayerObject) -> void:
|
|||||||
new_player_object.point_given_sound.connect(_on_make_point_given_sound)
|
new_player_object.point_given_sound.connect(_on_make_point_given_sound)
|
||||||
new_player_object.player_point_given.connect(_on_point_given.bind(new_player_object))
|
new_player_object.player_point_given.connect(_on_point_given.bind(new_player_object))
|
||||||
new_player_object.player_point_taken.connect(_on_point_taken.bind(new_player_object))
|
new_player_object.player_point_taken.connect(_on_point_taken.bind(new_player_object))
|
||||||
|
save_soon()
|
||||||
|
|
||||||
|
|
||||||
func _on_point_given(player_object: PlayerObject) -> void:
|
func _on_point_given(player_object: PlayerObject) -> void:
|
||||||
@@ -259,6 +384,7 @@ func _on_player_won(player_object: PlayerObject) -> void:
|
|||||||
if Settings.add_to_stats:
|
if Settings.add_to_stats:
|
||||||
Settings.add_to_stats = false
|
Settings.add_to_stats = false
|
||||||
stats_paused_by_win = true
|
stats_paused_by_win = true
|
||||||
|
save_soon()
|
||||||
|
|
||||||
|
|
||||||
func _on_player_removed(player_object: PlayerObject) -> void:
|
func _on_player_removed(player_object: PlayerObject) -> void:
|
||||||
@@ -273,6 +399,7 @@ func _on_change_character_requested(new_player: PlayerRow, player_name: String)
|
|||||||
character_select_popup.show_grid(player_name)
|
character_select_popup.show_grid(player_name)
|
||||||
|
|
||||||
|
|
||||||
func _on_new_character_selected(character_texture: ImageTexture) -> void:
|
func _on_new_character_selected(character_texture: ImageTexture, character_name: String) -> void:
|
||||||
GameLog.debug("_on_new_character_selected")
|
GameLog.debug("_on_new_character_selected")
|
||||||
current_player_row.new_character_selected(character_texture)
|
current_player_row.new_character_selected(character_texture, character_name)
|
||||||
|
save_soon()
|
||||||
@@ -13,6 +13,10 @@ extends BasePopup
|
|||||||
|
|
||||||
## The most seconds of a song's beginning that can be hidden.
|
## The most seconds of a song's beginning that can be hidden.
|
||||||
const MAX_HIDE_SECONDS: float = 10.0
|
const MAX_HIDE_SECONDS: float = 10.0
|
||||||
|
## The shortest and longest part of a song that Short songs plays, and the step between.
|
||||||
|
const MIN_SHORT_SONG_SECONDS: int = 10
|
||||||
|
const MAX_SHORT_SONG_SECONDS: int = 120
|
||||||
|
const SHORT_SONG_STEP: int = 5
|
||||||
## Space between the rows and the card's edges.
|
## Space between the rows and the card's edges.
|
||||||
const PADDING: Vector2 = Vector2(16, 12)
|
const PADDING: Vector2 = Vector2(16, 12)
|
||||||
|
|
||||||
@@ -21,6 +25,11 @@ const PADDING: Vector2 = Vector2(16, 12)
|
|||||||
@onready var auto_repeat_button: CheckButton = %AutoRepeatCheckButton
|
@onready var auto_repeat_button: CheckButton = %AutoRepeatCheckButton
|
||||||
@onready var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton
|
@onready var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton
|
||||||
@onready var hide_next_track_button: CheckButton = %HideNextTrackCheckButton
|
@onready var hide_next_track_button: CheckButton = %HideNextTrackCheckButton
|
||||||
|
@onready var short_songs_button: CheckButton = %ShortSongsCheckButton
|
||||||
|
@onready var short_song_seconds_row: HBoxContainer = %ShortSongSecondsRow
|
||||||
|
@onready var short_song_seconds_label: Label = %ShortSongSecondsLabel
|
||||||
|
@onready var lower_short_song_seconds_button: Button = %LowerShortSongSecondsButton
|
||||||
|
@onready var increase_short_song_seconds_button: Button = %IncreaseShortSongSecondsButton
|
||||||
@onready var hide_beginning_button: CheckButton = %HideBeginningCheckButton
|
@onready var hide_beginning_button: CheckButton = %HideBeginningCheckButton
|
||||||
@onready var hide_for_seconds_row: HBoxContainer = %HideForSecondsRow
|
@onready var hide_for_seconds_row: HBoxContainer = %HideForSecondsRow
|
||||||
@onready var hide_for_seconds_label: Label = %HideForSecondsLabel
|
@onready var hide_for_seconds_label: Label = %HideForSecondsLabel
|
||||||
@@ -41,6 +50,11 @@ func _ready() -> void:
|
|||||||
auto_repeat_button.toggled.connect(_save_toggle.bind(&"auto_repeat_song"))
|
auto_repeat_button.toggled.connect(_save_toggle.bind(&"auto_repeat_song"))
|
||||||
stop_after_current_button.toggled.connect(_save_toggle.bind(&"stop_after_current"))
|
stop_after_current_button.toggled.connect(_save_toggle.bind(&"stop_after_current"))
|
||||||
hide_next_track_button.toggled.connect(_save_toggle.bind(&"hide_next_track"))
|
hide_next_track_button.toggled.connect(_save_toggle.bind(&"hide_next_track"))
|
||||||
|
short_songs_button.toggled.connect(_on_short_songs_toggled)
|
||||||
|
lower_short_song_seconds_button.pressed.connect(
|
||||||
|
set_short_song_seconds.bind(-SHORT_SONG_STEP, true))
|
||||||
|
increase_short_song_seconds_button.pressed.connect(
|
||||||
|
set_short_song_seconds.bind(SHORT_SONG_STEP, true))
|
||||||
hide_beginning_button.toggled.connect(_on_hide_beginning_toggled)
|
hide_beginning_button.toggled.connect(_on_hide_beginning_toggled)
|
||||||
hide_length_button.toggled.connect(_save_toggle.bind(&"hide_length"))
|
hide_length_button.toggled.connect(_save_toggle.bind(&"hide_length"))
|
||||||
hide_ticks_button.toggled.connect(_save_toggle.bind(&"hide_ticks"))
|
hide_ticks_button.toggled.connect(_save_toggle.bind(&"hide_ticks"))
|
||||||
@@ -60,6 +74,9 @@ func show_settings() -> void:
|
|||||||
auto_repeat_button.set_pressed_no_signal(Settings.auto_repeat_song)
|
auto_repeat_button.set_pressed_no_signal(Settings.auto_repeat_song)
|
||||||
stop_after_current_button.set_pressed_no_signal(Settings.stop_after_current)
|
stop_after_current_button.set_pressed_no_signal(Settings.stop_after_current)
|
||||||
hide_next_track_button.set_pressed_no_signal(Settings.hide_next_track)
|
hide_next_track_button.set_pressed_no_signal(Settings.hide_next_track)
|
||||||
|
short_songs_button.set_pressed_no_signal(Settings.short_songs)
|
||||||
|
short_song_seconds_row.visible = Settings.short_songs
|
||||||
|
short_song_seconds_label.text = str(Settings.short_song_seconds)
|
||||||
hide_beginning_button.set_pressed_no_signal(Settings.hide_beginning)
|
hide_beginning_button.set_pressed_no_signal(Settings.hide_beginning)
|
||||||
hide_length_button.set_pressed_no_signal(Settings.hide_length)
|
hide_length_button.set_pressed_no_signal(Settings.hide_length)
|
||||||
hide_ticks_button.set_pressed_no_signal(Settings.hide_ticks)
|
hide_ticks_button.set_pressed_no_signal(Settings.hide_ticks)
|
||||||
@@ -117,6 +134,23 @@ func _save_toggle(on: bool, setting: StringName) -> void:
|
|||||||
Settings.save_settings()
|
Settings.save_settings()
|
||||||
|
|
||||||
|
|
||||||
|
## Sets how many seconds of each song Short songs plays, between
|
||||||
|
## [constant MIN_SHORT_SONG_SECONDS] and [constant MAX_SHORT_SONG_SECONDS]. With
|
||||||
|
## [param relative] the seconds are added to the current length.
|
||||||
|
func set_short_song_seconds(seconds: int, relative: bool = false) -> void:
|
||||||
|
var length: int = Settings.short_song_seconds + seconds if relative else seconds
|
||||||
|
Settings.short_song_seconds = clampi(length, MIN_SHORT_SONG_SECONDS,
|
||||||
|
MAX_SHORT_SONG_SECONDS)
|
||||||
|
short_song_seconds_label.text = str(Settings.short_song_seconds)
|
||||||
|
Settings.save_settings()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_short_songs_toggled(on: bool) -> void:
|
||||||
|
_save_toggle(on, &"short_songs")
|
||||||
|
short_song_seconds_row.visible = on
|
||||||
|
_fit_to_rows()
|
||||||
|
|
||||||
|
|
||||||
func _on_hide_beginning_toggled(on: bool) -> void:
|
func _on_hide_beginning_toggled(on: bool) -> void:
|
||||||
_save_toggle(on, &"hide_beginning")
|
_save_toggle(on, &"hide_beginning")
|
||||||
hide_for_seconds_row.visible = on
|
hide_for_seconds_row.visible = on
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ signal next_pressed
|
|||||||
## "Show winner".
|
## "Show winner".
|
||||||
signal show_winner_pressed
|
signal show_winner_pressed
|
||||||
|
|
||||||
const NEXT_TEXT: String = "Randomize new track"
|
const NEXT_TEXT: String = "Next song"
|
||||||
const SHOW_WINNER_TEXT: String = "Show winner"
|
const SHOW_WINNER_TEXT: String = "Show winner"
|
||||||
|
|
||||||
## True while Next reads "Show winner", after the last song of a prepared quiz.
|
## True while Next reads "Show winner", after the last song of a prepared quiz.
|
||||||
|
|||||||
+57
-9
@@ -15,35 +15,79 @@ signal song_list_loaded
|
|||||||
## Emitted when a new song has been downloaded and added to the Playlist.
|
## Emitted when a new song has been downloaded and added to the Playlist.
|
||||||
signal song_added
|
signal song_added
|
||||||
|
|
||||||
|
## Emitted when the songs of a saved quiz could not be continued, because the server's
|
||||||
|
## playlist no longer has them, e.g. after a sync. The quiz starts over.
|
||||||
|
signal saved_songs_dropped
|
||||||
|
|
||||||
## Seconds to wait before retrying a download step that failed.
|
## Seconds to wait before retrying a download step that failed.
|
||||||
var retry_seconds: float = 5.0
|
var retry_seconds: float = 5.0
|
||||||
|
|
||||||
|
|
||||||
## Loads the songs already in the server playlist, then starts preloading new ones.
|
## Loads the songs already in the server playlist, then starts preloading new ones.
|
||||||
## Preloading waits for the list, because loading the list clears the playlist.
|
## Preloading waits for the list, because loading the list clears the playlist.
|
||||||
func load_song_list() -> void:
|
##
|
||||||
|
## With [param saved], the songs of a saved quiz are used where they match the server's
|
||||||
|
## playlist, so who got points and what was answered or downloaded ahead is kept. When
|
||||||
|
## the server's playlist no longer has them, they are dropped and
|
||||||
|
## [signal saved_songs_dropped] is emitted.
|
||||||
|
func load_song_list(saved: QuizSave = null) -> void:
|
||||||
GameLog.debug("load_song_list")
|
GameLog.debug("load_song_list")
|
||||||
if Settings.is_offline():
|
if Settings.is_offline():
|
||||||
_start_folder_playlist()
|
_start_folder_playlist(saved)
|
||||||
return
|
return
|
||||||
var song_list_fetched: Callable = func song_list_fetched(songs: Array[SongInfo]) -> void:
|
var song_list_fetched: Callable = func song_list_fetched(songs: Array[SongInfo]) -> void:
|
||||||
if Settings.is_offline():
|
if Settings.is_offline():
|
||||||
return
|
return
|
||||||
Playlist.clear_song_list()
|
Playlist.clear_song_list()
|
||||||
for info: SongInfo in songs:
|
var continued: int = 0
|
||||||
|
if saved != null && !saved.songs.is_empty():
|
||||||
|
if saved_songs_match(saved.songs, songs):
|
||||||
|
continued = saved.songs.size()
|
||||||
|
_continue_songs(saved)
|
||||||
|
else:
|
||||||
|
GameLog.warning("The server's playlist changed, so the saved quiz starts over")
|
||||||
|
saved_songs_dropped.emit()
|
||||||
|
for info: SongInfo in songs.slice(continued):
|
||||||
var new_song_object: SongObject = SongObject.new()
|
var new_song_object: SongObject = SongObject.new()
|
||||||
new_song_object.song_number = info.song_no
|
new_song_object.song_number = info.song_no
|
||||||
new_song_object.soundtrack_title = info.soundtrack
|
new_song_object.soundtrack_title = info.soundtrack
|
||||||
new_song_object.song_title = info.song
|
new_song_object.song_title = info.song
|
||||||
|
new_song_object.soundtrack_uuid = info.soundtrack_uuid
|
||||||
new_song_object.is_answered = true
|
new_song_object.is_answered = true
|
||||||
new_song_object.has_played = true
|
new_song_object.has_played = true
|
||||||
Playlist.add_to_playlist(new_song_object)
|
Playlist.add_to_playlist(new_song_object)
|
||||||
|
if continued == 0:
|
||||||
Playlist.update_last_played_song()
|
Playlist.update_last_played_song()
|
||||||
# Songs already on the server were played before, so the game starts after them
|
# Songs already on the server were played before, so the game starts after them
|
||||||
Playlist.game_start_song = Playlist.song_object_array.size()
|
Playlist.game_start_song = Playlist.song_object_array.size()
|
||||||
song_list_loaded.emit()
|
song_list_loaded.emit()
|
||||||
fetch_song_if_idle()
|
# Songs downloaded ahead before a restart already fill some of the cache
|
||||||
MusicServerClient.fetch_song_list(song_list_fetched, _retry_later.bind(load_song_list))
|
fill_cache()
|
||||||
|
MusicServerClient.fetch_song_list(song_list_fetched,
|
||||||
|
_retry_later.bind(load_song_list.bind(saved)))
|
||||||
|
|
||||||
|
|
||||||
|
## Whether [param saved_songs] are the first songs of the server's playlist [param songs]:
|
||||||
|
## the same numbers, soundtracks and titles in the same order.
|
||||||
|
static func saved_songs_match(saved_songs: Array[SongObject], songs: Array[SongInfo]) -> bool:
|
||||||
|
if saved_songs.size() > songs.size():
|
||||||
|
return false
|
||||||
|
for i: int in saved_songs.size():
|
||||||
|
if saved_songs[i].song_number != songs[i].song_no \
|
||||||
|
|| saved_songs[i].soundtrack_title != songs[i].soundtrack \
|
||||||
|
|| saved_songs[i].song_title != songs[i].song:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# Puts the saved songs in the playlist, where the quiz was
|
||||||
|
func _continue_songs(saved: QuizSave) -> void:
|
||||||
|
for song_object: SongObject in saved.songs:
|
||||||
|
Playlist.add_to_playlist(song_object)
|
||||||
|
Playlist.currently_playing_song = saved.currently_playing_song
|
||||||
|
Playlist.latest_played_song = saved.latest_played_song
|
||||||
|
Playlist.game_start_song = saved.game_start_song
|
||||||
|
Playlist.first_song_played = saved.first_song_played
|
||||||
|
|
||||||
|
|
||||||
## Starts downloading one more song, which keeps downloading until the cache is full.
|
## Starts downloading one more song, which keeps downloading until the cache is full.
|
||||||
@@ -79,7 +123,7 @@ func _download_random_song(new_song_object: SongObject) -> void:
|
|||||||
return
|
return
|
||||||
Playlist.store_song(new_song_object, body)
|
Playlist.store_song(new_song_object, body)
|
||||||
_add_song_to_queue(new_song_object)
|
_add_song_to_queue(new_song_object)
|
||||||
MusicServerClient.fetch_random_song(Settings.use_low_played_mode, song_downloaded,
|
MusicServerClient.fetch_random_song(Settings.random_mode, song_downloaded,
|
||||||
_retry_later.bind(_download_random_song.bind(new_song_object)))
|
_retry_later.bind(_download_random_song.bind(new_song_object)))
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +140,7 @@ func _fetch_song_info(new_song_object: SongObject) -> void:
|
|||||||
new_song_object.song_number = info.song_no
|
new_song_object.song_number = info.song_no
|
||||||
new_song_object.soundtrack_title = info.soundtrack
|
new_song_object.soundtrack_title = info.soundtrack
|
||||||
new_song_object.song_title = info.song
|
new_song_object.song_title = info.song
|
||||||
|
new_song_object.soundtrack_uuid = info.soundtrack_uuid
|
||||||
GameLog.debug("new_song_object: ", new_song_object.to_string())
|
GameLog.debug("new_song_object: ", new_song_object.to_string())
|
||||||
Playlist.add_to_playlist(new_song_object)
|
Playlist.add_to_playlist(new_song_object)
|
||||||
if Playlist.should_preload_more_songs():
|
if Playlist.should_preload_more_songs():
|
||||||
@@ -114,14 +159,17 @@ func _retry_later(step: Callable) -> void:
|
|||||||
step.call())
|
step.call())
|
||||||
|
|
||||||
|
|
||||||
# A music folder has no playlist on a server, so the game starts with an empty one
|
# A music folder has no playlist on a server, so the game starts with an empty one, or
|
||||||
func _start_folder_playlist() -> void:
|
# with the saved quiz's songs
|
||||||
|
func _start_folder_playlist(saved: QuizSave = null) -> void:
|
||||||
Playlist.clear_song_list()
|
Playlist.clear_song_list()
|
||||||
Playlist.update_last_played_song()
|
Playlist.update_last_played_song()
|
||||||
Playlist.game_start_song = 0
|
Playlist.game_start_song = 0
|
||||||
|
if saved != null && !saved.songs.is_empty():
|
||||||
|
_continue_songs(saved)
|
||||||
Playlist.is_song_downloading = false
|
Playlist.is_song_downloading = false
|
||||||
song_list_loaded.emit()
|
song_list_loaded.emit()
|
||||||
fetch_song_if_idle()
|
fill_cache()
|
||||||
|
|
||||||
|
|
||||||
# Picks songs from the music folder until enough are ready. A prepared quiz runs out
|
# Picks songs from the music folder until enough are ready. A prepared quiz runs out
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||||
|
# Do not edit; change the server and generate again.
|
||||||
|
class_name AlternativeNames
|
||||||
|
extends RefCounted
|
||||||
|
## The [code]AlternativeNames[/code] schema of the MusicServer API.
|
||||||
|
|
||||||
|
## The new list of alternative names. Replaces the old list; send [] to remove them all.
|
||||||
|
var names: Array[String]
|
||||||
|
|
||||||
|
|
||||||
|
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||||
|
static func from_dict(data: Dictionary) -> AlternativeNames:
|
||||||
|
var result: AlternativeNames = AlternativeNames.new()
|
||||||
|
result.names.assign(ApiJson.get_array(data, "names"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||||
|
func to_dict() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"names": names,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bpdabeiw5wupm
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||||
|
# Do not edit; change the server and generate again.
|
||||||
|
class_name CoverSource
|
||||||
|
extends RefCounted
|
||||||
|
## The [code]CoverSource[/code] schema of the MusicServer API.
|
||||||
|
|
||||||
|
## The game's ID on SteamGridDB, or the anime's ID on AniList
|
||||||
|
var id: String
|
||||||
|
## Where the cover comes from
|
||||||
|
var source: String
|
||||||
|
|
||||||
|
|
||||||
|
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||||
|
static func from_dict(data: Dictionary) -> CoverSource:
|
||||||
|
var result: CoverSource = CoverSource.new()
|
||||||
|
result.id = ApiJson.get_string(data, "id", "")
|
||||||
|
result.source = ApiJson.get_string(data, "source", "")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||||
|
func to_dict() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://06q8sppe2evn
|
||||||
@@ -5,6 +5,7 @@ extends RefCounted
|
|||||||
## The [code]ProgressResponse[/code] schema of the MusicServer API.
|
## The [code]ProgressResponse[/code] schema of the MusicServer API.
|
||||||
|
|
||||||
var progress: String
|
var progress: String
|
||||||
|
## How long the sync has been running, as minutes and seconds, e.g. "02:15"
|
||||||
var time_spent: String
|
var time_spent: String
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ extends RefCounted
|
|||||||
## The [code]Soundtrack[/code] schema of the MusicServer API.
|
## The [code]Soundtrack[/code] schema of the MusicServer API.
|
||||||
|
|
||||||
var added: String
|
var added: String
|
||||||
|
## Other names the soundtrack is known by
|
||||||
|
var alternative_names: Array[String]
|
||||||
|
## When the cover last changed, in Unix seconds. Missing when there is no cover.
|
||||||
|
var cover_modified: int
|
||||||
var last_played: String
|
var last_played: String
|
||||||
var name: String
|
var name: String
|
||||||
var times_played: int
|
var times_played: int
|
||||||
@@ -15,6 +19,8 @@ var uuid: String
|
|||||||
static func from_dict(data: Dictionary) -> Soundtrack:
|
static func from_dict(data: Dictionary) -> Soundtrack:
|
||||||
var result: Soundtrack = Soundtrack.new()
|
var result: Soundtrack = Soundtrack.new()
|
||||||
result.added = ApiJson.get_string(data, "added", "")
|
result.added = ApiJson.get_string(data, "added", "")
|
||||||
|
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
|
||||||
|
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
|
||||||
result.last_played = ApiJson.get_string(data, "last_played", "")
|
result.last_played = ApiJson.get_string(data, "last_played", "")
|
||||||
result.name = ApiJson.get_string(data, "name", "")
|
result.name = ApiJson.get_string(data, "name", "")
|
||||||
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
||||||
@@ -26,6 +32,8 @@ static func from_dict(data: Dictionary) -> Soundtrack:
|
|||||||
func to_dict() -> Dictionary:
|
func to_dict() -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"added": added,
|
"added": added,
|
||||||
|
"alternative_names": alternative_names,
|
||||||
|
"cover_modified": cover_modified,
|
||||||
"last_played": last_played,
|
"last_played": last_played,
|
||||||
"name": name,
|
"name": name,
|
||||||
"times_played": times_played,
|
"times_played": times_played,
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ extends RefCounted
|
|||||||
## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API.
|
## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API.
|
||||||
|
|
||||||
var added: String
|
var added: String
|
||||||
|
## Other names the soundtrack is known by
|
||||||
|
var alternative_names: Array[String]
|
||||||
|
## When the cover last changed, in Unix seconds. Missing when there is no cover.
|
||||||
|
var cover_modified: int
|
||||||
var last_played: String
|
var last_played: String
|
||||||
var name: String
|
var name: String
|
||||||
var songs: Array[Song]
|
var songs: Array[Song]
|
||||||
@@ -16,6 +20,8 @@ var uuid: String
|
|||||||
static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
|
static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
|
||||||
var result: SoundtrackWithSongList = SoundtrackWithSongList.new()
|
var result: SoundtrackWithSongList = SoundtrackWithSongList.new()
|
||||||
result.added = ApiJson.get_string(data, "added", "")
|
result.added = ApiJson.get_string(data, "added", "")
|
||||||
|
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
|
||||||
|
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
|
||||||
result.last_played = ApiJson.get_string(data, "last_played", "")
|
result.last_played = ApiJson.get_string(data, "last_played", "")
|
||||||
result.name = ApiJson.get_string(data, "name", "")
|
result.name = ApiJson.get_string(data, "name", "")
|
||||||
for item: Variant in ApiJson.get_array(data, "songs"):
|
for item: Variant in ApiJson.get_array(data, "songs"):
|
||||||
@@ -30,6 +36,8 @@ static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
|
|||||||
func to_dict() -> Dictionary:
|
func to_dict() -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"added": added,
|
"added": added,
|
||||||
|
"alternative_names": alternative_names,
|
||||||
|
"cover_modified": cover_modified,
|
||||||
"last_played": last_played,
|
"last_played": last_played,
|
||||||
"name": name,
|
"name": name,
|
||||||
"songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()),
|
"songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ class_name SoundtrackWithSongs
|
|||||||
extends RefCounted
|
extends RefCounted
|
||||||
## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API.
|
## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API.
|
||||||
|
|
||||||
|
## Other names the soundtrack is known by
|
||||||
|
var alternative_names: Array[String]
|
||||||
|
## When the cover last changed, in Unix seconds. Missing when there is no cover.
|
||||||
|
var cover_modified: int
|
||||||
var songs: Array[SongInfoForStats]
|
var songs: Array[SongInfoForStats]
|
||||||
var soundtrack_id: int
|
var soundtrack_id: int
|
||||||
var soundtrack_last_played: String
|
var soundtrack_last_played: String
|
||||||
@@ -15,6 +19,8 @@ var soundtrack_uuid: String
|
|||||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||||
static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
|
static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
|
||||||
var result: SoundtrackWithSongs = SoundtrackWithSongs.new()
|
var result: SoundtrackWithSongs = SoundtrackWithSongs.new()
|
||||||
|
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
|
||||||
|
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
|
||||||
for item: Variant in ApiJson.get_array(data, "songs"):
|
for item: Variant in ApiJson.get_array(data, "songs"):
|
||||||
if item is Dictionary:
|
if item is Dictionary:
|
||||||
result.songs.append(SongInfoForStats.from_dict(item))
|
result.songs.append(SongInfoForStats.from_dict(item))
|
||||||
@@ -29,6 +35,8 @@ static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
|
|||||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||||
func to_dict() -> Dictionary:
|
func to_dict() -> Dictionary:
|
||||||
return {
|
return {
|
||||||
|
"alternative_names": alternative_names,
|
||||||
|
"cover_modified": cover_modified,
|
||||||
"songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()),
|
"songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()),
|
||||||
"soundtrack_id": soundtrack_id,
|
"soundtrack_id": soundtrack_id,
|
||||||
"soundtrack_last_played": soundtrack_last_played,
|
"soundtrack_last_played": soundtrack_last_played,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ var soundtracks_changed_content: Array[String]
|
|||||||
var soundtracks_changed_title: Dictionary[String, String]
|
var soundtracks_changed_title: Dictionary[String, String]
|
||||||
var soundtracks_re_added: Array[String]
|
var soundtracks_re_added: Array[String]
|
||||||
var soundtracks_removed: Array[String]
|
var soundtracks_removed: Array[String]
|
||||||
|
## How long the sync took, as minutes and seconds, e.g. "02:15"
|
||||||
var total_time: String
|
var total_time: String
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,17 +18,21 @@ func _init(owner: Node) -> void:
|
|||||||
## Requests [param url] and calls [param on_complete] with
|
## Requests [param url] and calls [param on_complete] with
|
||||||
## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code],
|
## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code],
|
||||||
## the headers as [code]"Name: value"[/code] lines. Connection errors and timeouts are
|
## the headers as [code]"Name: value"[/code] lines. Connection errors and timeouts are
|
||||||
## reported with response code 0 and no headers. [param headers] are sent as-is; a
|
## reported with response code 0 and no headers. [param headers], which name the
|
||||||
## [param body] is sent as JSON.
|
## [param body]'s type, are sent as-is. Without [param follow_redirects] a redirect is
|
||||||
|
## answered as it is, with its [code]Location[/code] header.
|
||||||
func request(
|
func request(
|
||||||
url: String,
|
url: String,
|
||||||
method: int,
|
method: int,
|
||||||
on_complete: Callable,
|
on_complete: Callable,
|
||||||
headers: PackedStringArray = PackedStringArray(),
|
headers: PackedStringArray = PackedStringArray(),
|
||||||
body: String = "",
|
body: PackedByteArray = PackedByteArray(),
|
||||||
|
follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
var http_request: HTTPRequest = HTTPRequest.new()
|
var http_request: HTTPRequest = HTTPRequest.new()
|
||||||
http_request.timeout = timeout_seconds
|
http_request.timeout = timeout_seconds
|
||||||
|
if !follow_redirects:
|
||||||
|
http_request.max_redirects = 0
|
||||||
_owner.add_child(http_request)
|
_owner.add_child(http_request)
|
||||||
http_request.request_completed.connect(
|
http_request.request_completed.connect(
|
||||||
func(
|
func(
|
||||||
@@ -43,10 +47,7 @@ func request(
|
|||||||
return
|
return
|
||||||
on_complete.call(response_code, response_body, response_headers)
|
on_complete.call(response_code, response_body, response_headers)
|
||||||
)
|
)
|
||||||
var all_headers: PackedStringArray = headers.duplicate()
|
var error: int = http_request.request_raw(url, headers, method, body)
|
||||||
if method == HTTPClient.METHOD_PUT || !body.is_empty():
|
|
||||||
all_headers.append("Content-Type: application/json")
|
|
||||||
var error: int = http_request.request(url, all_headers, method, body)
|
|
||||||
if error != OK:
|
if error != OK:
|
||||||
http_request.queue_free()
|
http_request.queue_free()
|
||||||
push_error("Could not send request to %s (error %d)" % [url, error])
|
push_error("Could not send request to %s (error %d)" % [url, error])
|
||||||
|
|||||||
+144
-36
@@ -19,7 +19,7 @@ extends Node
|
|||||||
## JSON objects are handed to [code]on_done[/code] as the classes generated from the
|
## JSON objects are handed to [code]on_done[/code] as the classes generated from the
|
||||||
## server's OpenAPI spec (see [code]server/generated/[/code]), lists of them as typed
|
## server's OpenAPI spec (see [code]server/generated/[/code]), lists of them as typed
|
||||||
## Arrays. [code]on_failed[/code] is called with no arguments when a request fails,
|
## Arrays. [code]on_failed[/code] is called with no arguments when a request fails,
|
||||||
## including when no token could be had.
|
## including when no token could be had; [member last_error] then has the server's reason.
|
||||||
|
|
||||||
## Path of the API on the server, added between [code]Settings.default_path[/code] and
|
## Path of the API on the server, added between [code]Settings.default_path[/code] and
|
||||||
## each endpoint.
|
## each endpoint.
|
||||||
@@ -28,9 +28,20 @@ const API_PATH: String = "/api/v1"
|
|||||||
const CLIENT_TYPE: String = "godot"
|
const CLIENT_TYPE: String = "godot"
|
||||||
## Header with the ID of the client's play queue, in requests and responses.
|
## Header with the ID of the client's play queue, in requests and responses.
|
||||||
const QUEUE_HEADER: String = "X-Queue-Id"
|
const QUEUE_HEADER: String = "X-Queue-Id"
|
||||||
|
## The type of the JSON bodies sent.
|
||||||
|
const JSON_TYPE: String = "application/json"
|
||||||
|
## The files of a MusicPlayer release that can be downloaded: the Windows game, the Linux
|
||||||
|
## game, and the Linux package with an installer.
|
||||||
|
const DOWNLOAD_ASSETS: Array[String] = ["windows", "linux", "linux-package"]
|
||||||
|
## Where the cover of a soundtrack can be picked from, by the game's or anime's ID there.
|
||||||
|
const COVER_SOURCES: Array[String] = ["steamgriddb", "anilist"]
|
||||||
|
|
||||||
## Sends the actual HTTP requests. Swap it out in tests.
|
## Sends the actual HTTP requests. Swap it out in tests.
|
||||||
var transport: HttpTransport
|
var transport: HttpTransport
|
||||||
|
## The server's message for the request that failed last, e.g. [code]"covers are
|
||||||
|
## disabled"[/code], or empty when it gave none. Read it in an [code]on_failed[/code]
|
||||||
|
## callback, before another request can fail.
|
||||||
|
var last_error: String = ""
|
||||||
|
|
||||||
var _token: String = ""
|
var _token: String = ""
|
||||||
# The server the token is for; switching server in the settings needs a new token
|
# The server the token is for; switching server in the settings needs a new token
|
||||||
@@ -51,17 +62,22 @@ func fetch_song_list(on_done: Callable, on_failed: Callable = Callable()) -> voi
|
|||||||
get_json_list("/music/list", SongInfo, on_done, on_failed)
|
get_json_list("/music/list", SongInfo, on_done, on_failed)
|
||||||
|
|
||||||
|
|
||||||
## Downloads a random song as MP3 bytes. With [param low_played] the server prefers
|
## Downloads a random song as MP3 bytes, picked the way [param mode] (a
|
||||||
## soundtracks that have been played the least.
|
## [code]Settings.RandomMode[/code]) says: a random soundtrack and then one of its songs,
|
||||||
func fetch_random_song(
|
## the same but preferring soundtracks played less than average, or every song with the
|
||||||
low_played: bool, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
## same chance.
|
||||||
get_bytes("/music/rand/low" if low_played else "/music/rand", on_done, on_failed)
|
func fetch_random_song(mode: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||||
|
get_bytes(random_song_path(mode), on_done, on_failed)
|
||||||
|
|
||||||
|
|
||||||
## Downloads a random song as MP3 bytes, picked the way the server did before low played
|
## The endpoint for random songs picked the way [param mode] says.
|
||||||
## mode existed: any soundtrack is as likely as any other.
|
static func random_song_path(mode: int) -> String:
|
||||||
func fetch_random_song_classic(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
match mode:
|
||||||
get_bytes("/music/rand/classic", on_done, on_failed)
|
Settings.RandomMode.LOW_PLAYED:
|
||||||
|
return "/music/rand/low"
|
||||||
|
Settings.RandomMode.CLASSIC:
|
||||||
|
return "/music/rand/classic"
|
||||||
|
return "/music/rand"
|
||||||
|
|
||||||
|
|
||||||
## Adds the most recently fetched random song to the server playlist.
|
## Adds the most recently fetched random song to the server playlist.
|
||||||
@@ -126,6 +142,61 @@ func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> v
|
|||||||
get_json_list("/soundtracks", Soundtrack, on_done, on_failed)
|
get_json_list("/soundtracks", Soundtrack, on_done, on_failed)
|
||||||
|
|
||||||
|
|
||||||
|
## Downloads the cover picture of the soundtrack with [param uuid] as bytes (JPEG or
|
||||||
|
## PNG). The server answers 404 while it has no cover for it.
|
||||||
|
func fetch_soundtrack_cover(
|
||||||
|
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||||
|
get_bytes(_cover_path(uuid), on_done, on_failed)
|
||||||
|
|
||||||
|
|
||||||
|
## Stores [param image], a JPEG, PNG or WebP picture of [param content_type] such as
|
||||||
|
## [code]"image/png"[/code], as the cover of the soundtrack with [param uuid]. The
|
||||||
|
## server's automatic search never replaces it.
|
||||||
|
func upload_soundtrack_cover(uuid: String, image: PackedByteArray, content_type: String,
|
||||||
|
on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||||
|
_request(_cover_path(uuid), HTTPClient.METHOD_PUT, _call_ignoring_body.bind(on_done),
|
||||||
|
on_failed, true, image, content_type)
|
||||||
|
|
||||||
|
|
||||||
|
## Makes the cover of the game or anime with [param id] on [param source], one of
|
||||||
|
## [constant COVER_SOURCES], the cover of the soundtrack with [param uuid]. The server
|
||||||
|
## downloads it before answering.
|
||||||
|
func set_soundtrack_cover_source(uuid: String, source: String, id: String,
|
||||||
|
on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||||
|
var request: CoverSource = CoverSource.new()
|
||||||
|
request.source = source
|
||||||
|
request.id = id
|
||||||
|
_request(_cover_path(uuid) + "/source", HTTPClient.METHOD_PUT,
|
||||||
|
_call_ignoring_body.bind(on_done), on_failed, true,
|
||||||
|
JSON.stringify(request.to_dict()).to_utf8_buffer())
|
||||||
|
|
||||||
|
|
||||||
|
## Deletes the cover of the soundtrack with [param uuid], also one set by hand, so the
|
||||||
|
## server's automatic search looks for a new one.
|
||||||
|
func delete_soundtrack_cover(
|
||||||
|
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||||
|
_request(_cover_path(uuid), HTTPClient.METHOD_DELETE, _call_ignoring_body.bind(on_done),
|
||||||
|
on_failed)
|
||||||
|
|
||||||
|
|
||||||
|
## Replaces the alternative names of the soundtrack with [param uuid] with
|
||||||
|
## [param names]. The server trims them, drops duplicates and the soundtrack's own name,
|
||||||
|
## and calls [param on_done] with the updated [Soundtrack].
|
||||||
|
func set_alternative_names(uuid: String, names: Array[String], on_done: Callable,
|
||||||
|
on_failed: Callable = Callable()) -> void:
|
||||||
|
var request: AlternativeNames = AlternativeNames.new()
|
||||||
|
request.names = names
|
||||||
|
var path: String = "/soundtracks/%s/alternative-names" % uuid.uri_encode()
|
||||||
|
var saved: Callable = func saved(data: Variant) -> void:
|
||||||
|
if !(data is Dictionary):
|
||||||
|
push_warning("Expected a JSON object from " + path)
|
||||||
|
_call_if_valid(on_failed)
|
||||||
|
return
|
||||||
|
on_done.call(Soundtrack.from_dict(data))
|
||||||
|
_request(path, HTTPClient.METHOD_PUT, _parse_json.bind(path, saved, on_failed), on_failed,
|
||||||
|
true, JSON.stringify(request.to_dict()).to_utf8_buffer())
|
||||||
|
|
||||||
|
|
||||||
## Fetches a soundtrack and its songs as a [SoundtrackWithSongList].
|
## Fetches a soundtrack and its songs as a [SoundtrackWithSongList].
|
||||||
func fetch_soundtrack(
|
func fetch_soundtrack(
|
||||||
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||||
@@ -278,14 +349,14 @@ func fetch_client_assets(on_done: Callable, on_failed: Callable = Callable()) ->
|
|||||||
get_json("/download/list", on_done, on_failed)
|
get_json("/download/list", on_done, on_failed)
|
||||||
|
|
||||||
|
|
||||||
## Downloads the newest MusicPlayer for Windows.
|
## Calls [param on_done] with the link to download the newest MusicPlayer file
|
||||||
func download_client_windows(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
## [param asset] from, one of [constant DOWNLOAD_ASSETS]. The server answers with a
|
||||||
get_bytes("/download/windows", on_done, on_failed)
|
## redirect to the release on Gitea, which is not followed: the link needs no token, so
|
||||||
|
## the browser can open it.
|
||||||
|
func fetch_client_download_url(
|
||||||
## Downloads the newest MusicPlayer for Linux.
|
asset: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||||
func download_client_linux(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
_request("/download/" + asset, HTTPClient.METHOD_GET, on_done, on_failed, true,
|
||||||
get_bytes("/download/linux", on_done, on_failed)
|
PackedByteArray(), JSON_TYPE, false)
|
||||||
|
|
||||||
|
|
||||||
# Tokens
|
# Tokens
|
||||||
@@ -323,14 +394,9 @@ func queue_id() -> String:
|
|||||||
|
|
||||||
# Keeps the queue ID in [param headers] from [param server] when it is new
|
# Keeps the queue ID in [param headers] from [param server] when it is new
|
||||||
func _remember_queue_id(server: String, headers: PackedStringArray) -> void:
|
func _remember_queue_id(server: String, headers: PackedStringArray) -> void:
|
||||||
var prefix: String = QUEUE_HEADER.to_lower() + ":"
|
var id: String = header_value(headers, QUEUE_HEADER)
|
||||||
for header: String in headers:
|
|
||||||
if !header.to_lower().begins_with(prefix):
|
|
||||||
continue
|
|
||||||
var id: String = header.substr(prefix.length()).strip_edges()
|
|
||||||
if !id.is_empty() && Settings.queue_ids.get(server, "") != id:
|
if !id.is_empty() && Settings.queue_ids.get(server, "") != id:
|
||||||
Settings.set_queue_id(server, id)
|
Settings.set_queue_id(server, id)
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
# Generic requests
|
# Generic requests
|
||||||
@@ -414,24 +480,36 @@ func _parse_json(
|
|||||||
|
|
||||||
|
|
||||||
# Sends the request with the token, and once more with a new token if the server
|
# Sends the request with the token, and once more with a new token if the server
|
||||||
# answers 401
|
# answers 401. A [param body] of the type [param content_type] is sent with it. Without
|
||||||
|
# [param follow_redirects], a redirect is a success and [param on_success] gets where it
|
||||||
|
# points to.
|
||||||
func _request(
|
func _request(
|
||||||
path: String,
|
path: String,
|
||||||
method: int,
|
method: int,
|
||||||
on_success: Callable,
|
on_success: Callable,
|
||||||
on_failed: Callable,
|
on_failed: Callable,
|
||||||
may_retry: bool = true,
|
may_retry: bool = true,
|
||||||
|
body: PackedByteArray = PackedByteArray(),
|
||||||
|
content_type: String = JSON_TYPE,
|
||||||
|
follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
_with_token(func(token: String) -> void:
|
_with_token(func(token: String) -> void:
|
||||||
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
|
_send(path, method, token, func(response_code: int, response: PackedByteArray,
|
||||||
|
headers: PackedStringArray) -> void:
|
||||||
if response_code == 401 && may_retry:
|
if response_code == 401 && may_retry:
|
||||||
GameLog.debug("Token refused by the server, getting a new one")
|
GameLog.debug("Token refused by the server, getting a new one")
|
||||||
if _token == token:
|
if _token == token:
|
||||||
clear_token()
|
clear_token()
|
||||||
_request(path, method, on_success, on_failed, false)
|
_request(path, method, on_success, on_failed, false, body, content_type,
|
||||||
|
follow_redirects)
|
||||||
return
|
return
|
||||||
_handle_response(path, response_code, body, on_success, on_failed)
|
var location: String = header_value(headers, "Location")
|
||||||
)
|
if !follow_redirects && response_code >= 300 && response_code < 400 \
|
||||||
|
&& !location.is_empty():
|
||||||
|
on_success.call(location)
|
||||||
|
return
|
||||||
|
_handle_response(path, response_code, response, on_success, on_failed)
|
||||||
|
, body, content_type, follow_redirects)
|
||||||
, on_failed)
|
, on_failed)
|
||||||
|
|
||||||
|
|
||||||
@@ -439,18 +517,27 @@ func _request(
|
|||||||
func _send_once(
|
func _send_once(
|
||||||
path: String, method: int, token: String, on_success: Callable, on_failed: Callable,
|
path: String, method: int, token: String, on_success: Callable, on_failed: Callable,
|
||||||
) -> void:
|
) -> void:
|
||||||
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
|
_send(path, method, token, func(response_code: int, body: PackedByteArray,
|
||||||
|
_headers: PackedStringArray) -> void:
|
||||||
_handle_response(path, response_code, body, on_success, on_failed)
|
_handle_response(path, response_code, body, on_success, on_failed)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
func _send(
|
func _send(
|
||||||
path: String, method: int, token: String, on_complete: Callable, body: String = "",
|
path: String,
|
||||||
|
method: int,
|
||||||
|
token: String,
|
||||||
|
on_complete: Callable,
|
||||||
|
body: PackedByteArray = PackedByteArray(),
|
||||||
|
content_type: String = JSON_TYPE,
|
||||||
|
follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
var server: String = Settings.default_path
|
var server: String = Settings.default_path
|
||||||
var headers: PackedStringArray = []
|
var headers: PackedStringArray = []
|
||||||
if !token.is_empty():
|
if !token.is_empty():
|
||||||
headers.append("Authorization: Bearer " + token)
|
headers.append("Authorization: Bearer " + token)
|
||||||
|
if !body.is_empty() || method == HTTPClient.METHOD_PUT:
|
||||||
|
headers.append("Content-Type: " + content_type)
|
||||||
var queue: String = Settings.queue_ids.get(server, "")
|
var queue: String = Settings.queue_ids.get(server, "")
|
||||||
if !queue.is_empty():
|
if !queue.is_empty():
|
||||||
headers.append("%s: %s" % [QUEUE_HEADER, queue])
|
headers.append("%s: %s" % [QUEUE_HEADER, queue])
|
||||||
@@ -458,8 +545,8 @@ func _send(
|
|||||||
func(response_code: int, response_body: PackedByteArray,
|
func(response_code: int, response_body: PackedByteArray,
|
||||||
response_headers: PackedStringArray) -> void:
|
response_headers: PackedStringArray) -> void:
|
||||||
_remember_queue_id(server, response_headers)
|
_remember_queue_id(server, response_headers)
|
||||||
on_complete.call(response_code, response_body)
|
on_complete.call(response_code, response_body, response_headers)
|
||||||
, headers, body)
|
, headers, body, follow_redirects)
|
||||||
|
|
||||||
|
|
||||||
func _handle_response(
|
func _handle_response(
|
||||||
@@ -469,7 +556,9 @@ func _handle_response(
|
|||||||
on_success: Callable,
|
on_success: Callable,
|
||||||
on_failed: Callable,
|
on_failed: Callable,
|
||||||
) -> void:
|
) -> void:
|
||||||
if response_code != 200:
|
# Requests that change something may answer 204 No Content
|
||||||
|
if response_code < 200 || response_code >= 300:
|
||||||
|
last_error = _error_message(body).trim_prefix(": ")
|
||||||
push_warning("Request to %s failed with response code %d%s"
|
push_warning("Request to %s failed with response code %d%s"
|
||||||
% [path, response_code, _error_message(body)])
|
% [path, response_code, _error_message(body)])
|
||||||
_call_if_valid(on_failed)
|
_call_if_valid(on_failed)
|
||||||
@@ -496,7 +585,7 @@ func _request_token() -> void:
|
|||||||
token_request.client_type = CLIENT_TYPE
|
token_request.client_type = CLIENT_TYPE
|
||||||
var server: String = Settings.default_path
|
var server: String = Settings.default_path
|
||||||
_send("/token", HTTPClient.METHOD_POST, "",
|
_send("/token", HTTPClient.METHOD_POST, "",
|
||||||
func(response_code: int, body: PackedByteArray) -> void:
|
func(response_code: int, body: PackedByteArray, _headers: PackedStringArray) -> void:
|
||||||
var waiters: Array[Array] = _token_waiters.duplicate()
|
var waiters: Array[Array] = _token_waiters.duplicate()
|
||||||
_token_waiters.clear()
|
_token_waiters.clear()
|
||||||
var token: String = _read_token(response_code, body)
|
var token: String = _read_token(response_code, body)
|
||||||
@@ -508,7 +597,7 @@ func _request_token() -> void:
|
|||||||
_token_server = server
|
_token_server = server
|
||||||
for waiter: Array in waiters:
|
for waiter: Array in waiters:
|
||||||
waiter[0].call(token)
|
waiter[0].call(token)
|
||||||
, JSON.stringify(token_request.to_dict()))
|
, JSON.stringify(token_request.to_dict()).to_utf8_buffer())
|
||||||
|
|
||||||
|
|
||||||
func _read_token(response_code: int, body: PackedByteArray) -> String:
|
func _read_token(response_code: int, body: PackedByteArray) -> String:
|
||||||
@@ -537,6 +626,25 @@ static func _parse_or_null(body: PackedByteArray) -> Variant:
|
|||||||
return json.data if json.parse(body.get_string_from_utf8()) == OK else null
|
return json.data if json.parse(body.get_string_from_utf8()) == OK else null
|
||||||
|
|
||||||
|
|
||||||
|
## The value of the header [param header_name] in [param headers], given as
|
||||||
|
## [code]"Name: value"[/code] lines, or empty when it isn't there. Names are matched
|
||||||
|
## without regard to case.
|
||||||
|
static func header_value(headers: PackedStringArray, header_name: String) -> String:
|
||||||
|
for header: String in headers:
|
||||||
|
var colon: int = header.find(":")
|
||||||
|
if colon > 0 && header.left(colon).strip_edges().to_lower() == header_name.to_lower():
|
||||||
|
return header.substr(colon + 1).strip_edges()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
static func _cover_path(uuid: String) -> String:
|
||||||
|
return "/soundtracks/%s/cover" % uuid.uri_encode()
|
||||||
|
|
||||||
|
|
||||||
|
static func _call_ignoring_body(_body: PackedByteArray, callback: Callable) -> void:
|
||||||
|
_call_if_valid(callback)
|
||||||
|
|
||||||
|
|
||||||
static func _call_if_valid(callback: Callable) -> void:
|
static func _call_if_valid(callback: Callable) -> void:
|
||||||
if callback.is_valid():
|
if callback.is_valid():
|
||||||
callback.call()
|
callback.call()
|
||||||
@@ -17,6 +17,23 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"AlternativeNames": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"names": {
|
||||||
|
"description": "The new list of alternative names. Replaces the old list; send [] to remove them all.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxItems": 50,
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"names"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"CharacterDetails": {
|
"CharacterDetails": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -36,6 +53,30 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"CoverSource": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"description": "The game's ID on SteamGridDB, or the anime's ID on AniList",
|
||||||
|
"maxLength": 50,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"description": "Where the cover comes from",
|
||||||
|
"enum": [
|
||||||
|
"steamgriddb",
|
||||||
|
"anilist"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"source",
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ProgressResponse": {
|
"ProgressResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -43,6 +84,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"time_spent": {
|
"time_spent": {
|
||||||
|
"description": "How long the sync has been running, as minutes and seconds, e.g. \"02:15\"",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -175,6 +217,18 @@
|
|||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"alternative_names": {
|
||||||
|
"description": "Other names the soundtrack is known by",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"cover_modified": {
|
||||||
|
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"last_played": {
|
"last_played": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -193,6 +247,7 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"uuid",
|
"uuid",
|
||||||
"name",
|
"name",
|
||||||
|
"alternative_names",
|
||||||
"times_played",
|
"times_played",
|
||||||
"added"
|
"added"
|
||||||
],
|
],
|
||||||
@@ -205,6 +260,18 @@
|
|||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"alternative_names": {
|
||||||
|
"description": "Other names the soundtrack is known by",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"cover_modified": {
|
||||||
|
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"last_played": {
|
"last_played": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -230,6 +297,7 @@
|
|||||||
"songs",
|
"songs",
|
||||||
"uuid",
|
"uuid",
|
||||||
"name",
|
"name",
|
||||||
|
"alternative_names",
|
||||||
"times_played",
|
"times_played",
|
||||||
"added"
|
"added"
|
||||||
],
|
],
|
||||||
@@ -238,6 +306,18 @@
|
|||||||
"SoundtrackWithSongs": {
|
"SoundtrackWithSongs": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"alternative_names": {
|
||||||
|
"description": "Other names the soundtrack is known by",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"cover_modified": {
|
||||||
|
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"songs": {
|
"songs": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/SongInfoForStats"
|
"$ref": "#/components/schemas/SongInfoForStats"
|
||||||
@@ -267,6 +347,7 @@
|
|||||||
"soundtrack_id",
|
"soundtrack_id",
|
||||||
"soundtrack_uuid",
|
"soundtrack_uuid",
|
||||||
"soundtrack_name",
|
"soundtrack_name",
|
||||||
|
"alternative_names",
|
||||||
"soundtrack_played",
|
"soundtrack_played",
|
||||||
"songs"
|
"songs"
|
||||||
],
|
],
|
||||||
@@ -367,6 +448,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"total_time": {
|
"total_time": {
|
||||||
|
"description": "How long the sync took, as minutes and seconds, e.g. \"02:15\"",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -805,6 +887,74 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/download/linux-package": {
|
||||||
|
"get": {
|
||||||
|
"description": "Redirects to the download URL of the latest MusicPlayer release for Linux (package with installer).",
|
||||||
|
"operationId": "download-client-linux-package",
|
||||||
|
"responses": {
|
||||||
|
"302": {
|
||||||
|
"description": "Found",
|
||||||
|
"headers": {
|
||||||
|
"Location": {
|
||||||
|
"schema": {
|
||||||
|
"description": "Download URL of the asset",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Bad Gateway"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Download the latest client for Linux (package with installer)",
|
||||||
|
"tags": [
|
||||||
|
"download"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/download/list": {
|
"/api/v1/download/list": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the file names of the latest MusicPlayer release.",
|
"description": "Returns the file names of the latest MusicPlayer release.",
|
||||||
@@ -2663,6 +2813,515 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/soundtracks/{uuid}/alternative-names": {
|
||||||
|
"put": {
|
||||||
|
"description": "Replaces the soundtrack's alternative names, which search and the cover search also use. Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.",
|
||||||
|
"operationId": "set-alternative-names",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "UUID of the soundtrack",
|
||||||
|
"in": "path",
|
||||||
|
"name": "uuid",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"description": "UUID of the soundtrack",
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AlternativeNames"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Soundtrack"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Set alternative names",
|
||||||
|
"tags": [
|
||||||
|
"library"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/soundtracks/{uuid}/cover": {
|
||||||
|
"delete": {
|
||||||
|
"description": "Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.",
|
||||||
|
"operationId": "delete-soundtrack-cover",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "UUID of the soundtrack or song",
|
||||||
|
"in": "path",
|
||||||
|
"name": "uuid",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"description": "UUID of the soundtrack or song",
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Service Unavailable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Remove the cover",
|
||||||
|
"tags": [
|
||||||
|
"covers"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.",
|
||||||
|
"operationId": "get-soundtrack-cover",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "UUID of the soundtrack or song",
|
||||||
|
"in": "path",
|
||||||
|
"name": "uuid",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"description": "UUID of the soundtrack or song",
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"image/jpeg": {
|
||||||
|
"schema": {
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"image/png": {
|
||||||
|
"schema": {
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"image/webp": {
|
||||||
|
"schema": {
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "The cover image"
|
||||||
|
},
|
||||||
|
"206": {
|
||||||
|
"content": {
|
||||||
|
"image/jpeg": {
|
||||||
|
"schema": {
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"image/png": {
|
||||||
|
"schema": {
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"image/webp": {
|
||||||
|
"schema": {
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Part of the file, for a Range request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Soundtrack cover",
|
||||||
|
"tags": [
|
||||||
|
"covers"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"description": "Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.",
|
||||||
|
"operationId": "upload-soundtrack-cover",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "UUID of the soundtrack",
|
||||||
|
"in": "path",
|
||||||
|
"name": "uuid",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"description": "UUID of the soundtrack",
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"image/*": {
|
||||||
|
"schema": {
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Service Unavailable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Upload a cover",
|
||||||
|
"tags": [
|
||||||
|
"covers"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/soundtracks/{uuid}/cover/source": {
|
||||||
|
"put": {
|
||||||
|
"description": "Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.",
|
||||||
|
"operationId": "set-soundtrack-cover-source",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "UUID of the soundtrack",
|
||||||
|
"in": "path",
|
||||||
|
"name": "uuid",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"description": "UUID of the soundtrack",
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CoverSource"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Bad Gateway"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Service Unavailable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Pick a cover",
|
||||||
|
"tags": [
|
||||||
|
"covers"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/statistics/songs/least-played": {
|
"/api/v1/statistics/songs/least-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the least played songs with their soundtrack.",
|
"description": "Returns the least played songs with their soundtrack.",
|
||||||
|
|||||||
@@ -12,9 +12,52 @@ script = ExtResource("card_script")
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
|
||||||
[node name="NameLabel" type="Label" parent="VBox" unique_id=1211000003]
|
[node name="Header" type="HBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="CoverFrame" type="Panel" parent="VBox/Header"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(0, 62)
|
clip_children = 2
|
||||||
|
custom_minimum_size = Vector2(68, 102)
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
theme_type_variation = &"SoundtrackCover"
|
||||||
|
|
||||||
|
[node name="CoverPlaceholder" type="Label" parent="VBox/Header/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
|
||||||
|
theme_type_variation = &"TextLarge"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 1
|
||||||
|
|
||||||
|
[node name="CoverPicture" type="TextureRect" parent="VBox/Header/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="VBox/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="NameLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000003]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 50)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
theme_type_variation = &"TextLarge"
|
theme_type_variation = &"TextLarge"
|
||||||
@@ -22,12 +65,12 @@ autowrap_mode = 3
|
|||||||
text_overrun_behavior = 3
|
text_overrun_behavior = 3
|
||||||
max_lines_visible = 2
|
max_lines_visible = 2
|
||||||
|
|
||||||
[node name="PlayedLabel" type="Label" parent="VBox" unique_id=1211000004]
|
[node name="PlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000004]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
|
||||||
[node name="LastPlayedLabel" type="Label" parent="VBox" unique_id=1211000005]
|
[node name="LastPlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000005]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ extends PanelContainer
|
|||||||
## played, and its most and least played songs. Songs never played are not listed, and
|
## played, and its most and least played songs. Songs never played are not listed, and
|
||||||
## the least played are only shown when there are enough played songs to not repeat
|
## the least played are only shown when there are enough played songs to not repeat
|
||||||
## any. Clicking it asks for all its songs.
|
## any. Clicking it asks for all its songs.
|
||||||
|
##
|
||||||
|
## Its cover shows on the left once [method load_cover] has fetched it; until then, and
|
||||||
|
## when the soundtrack has no cover, the soundtrack's initials stand in for it.
|
||||||
|
|
||||||
## Emitted when the card is clicked.
|
## Emitted when the card is clicked.
|
||||||
signal pressed(soundtrack: SoundtrackWithSongs)
|
signal pressed(soundtrack: SoundtrackWithSongs)
|
||||||
@@ -15,6 +18,11 @@ const SONGS_SHOWN: int = 3
|
|||||||
## The soundtrack the card shows.
|
## The soundtrack the card shows.
|
||||||
var soundtrack: SoundtrackWithSongs
|
var soundtrack: SoundtrackWithSongs
|
||||||
|
|
||||||
|
# The cover time asked for, so a cover is only asked for again when it changed
|
||||||
|
var _cover_asked: int = 0
|
||||||
|
|
||||||
|
@onready var cover_picture: TextureRect = %CoverPicture
|
||||||
|
@onready var cover_placeholder: Label = %CoverPlaceholder
|
||||||
@onready var name_label: Label = %NameLabel
|
@onready var name_label: Label = %NameLabel
|
||||||
@onready var played_label: Label = %PlayedLabel
|
@onready var played_label: Label = %PlayedLabel
|
||||||
@onready var last_played_label: Label = %LastPlayedLabel
|
@onready var last_played_label: Label = %LastPlayedLabel
|
||||||
@@ -32,7 +40,14 @@ func _ready() -> void:
|
|||||||
|
|
||||||
## Shows [param data] on the card.
|
## Shows [param data] on the card.
|
||||||
func show_soundtrack(data: SoundtrackWithSongs) -> void:
|
func show_soundtrack(data: SoundtrackWithSongs) -> void:
|
||||||
|
var cover_changed: bool = soundtrack == null \
|
||||||
|
|| soundtrack.soundtrack_uuid != data.soundtrack_uuid \
|
||||||
|
|| soundtrack.cover_modified != data.cover_modified
|
||||||
soundtrack = data
|
soundtrack = data
|
||||||
|
if cover_changed:
|
||||||
|
show_cover(null)
|
||||||
|
_cover_asked = 0
|
||||||
|
cover_placeholder.text = initials(data.soundtrack_name)
|
||||||
name_label.text = data.soundtrack_name
|
name_label.text = data.soundtrack_name
|
||||||
tooltip_text = data.soundtrack_name
|
tooltip_text = data.soundtrack_name
|
||||||
played_label.text = played_text(data.soundtrack_played)
|
played_label.text = played_text(data.soundtrack_played)
|
||||||
@@ -55,6 +70,42 @@ func show_soundtrack(data: SoundtrackWithSongs) -> void:
|
|||||||
_fill_song_list(least_played_list, least_played)
|
_fill_song_list(least_played_list, least_played)
|
||||||
|
|
||||||
|
|
||||||
|
## Asks [param covers] for the soundtrack's cover, unless it already has, and shows it
|
||||||
|
## when it comes. Soundtracks without a cover keep their initials.
|
||||||
|
func load_cover(covers: CoverCache) -> void:
|
||||||
|
if soundtrack == null || soundtrack.cover_modified <= 0 \
|
||||||
|
|| _cover_asked == soundtrack.cover_modified:
|
||||||
|
return
|
||||||
|
_cover_asked = soundtrack.cover_modified
|
||||||
|
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified, show_cover)
|
||||||
|
|
||||||
|
|
||||||
|
## Takes the soundtrack's new cover time [param modified], 0 when its cover was removed,
|
||||||
|
## and shows the initials until [method load_cover] fetches the new cover.
|
||||||
|
func set_cover_time(modified: int) -> void:
|
||||||
|
soundtrack.cover_modified = modified
|
||||||
|
show_cover(null)
|
||||||
|
_cover_asked = 0
|
||||||
|
|
||||||
|
|
||||||
|
## Shows [param cover], or the initials when it is null.
|
||||||
|
func show_cover(cover: Image) -> void:
|
||||||
|
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
|
||||||
|
cover_placeholder.visible = cover == null
|
||||||
|
|
||||||
|
|
||||||
|
## Up to two initials of [param soundtrack_name], e.g. "FF" for "Final Fantasy VI".
|
||||||
|
static func initials(soundtrack_name: String) -> String:
|
||||||
|
var letters: String = ""
|
||||||
|
for word: String in soundtrack_name.split(" ", false):
|
||||||
|
var first: String = word.left(1)
|
||||||
|
if first.to_upper() != first.to_lower():
|
||||||
|
letters += first.to_upper()
|
||||||
|
if letters.length() == 2:
|
||||||
|
break
|
||||||
|
return letters if !letters.is_empty() else soundtrack_name.left(1)
|
||||||
|
|
||||||
|
|
||||||
## "Played 3 times", or "Never played" for [code]0[/code].
|
## "Played 3 times", or "Never played" for [code]0[/code].
|
||||||
static func played_text(times_played: int) -> String:
|
static func played_text(times_played: int) -> String:
|
||||||
if times_played == 0:
|
if times_played == 0:
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ extends Panel
|
|||||||
## cards filtered on whether the soundtrack has been played.
|
## cards filtered on whether the soundtrack has been played.
|
||||||
##
|
##
|
||||||
## Shown instead of the quiz when the Statistics tab is chosen.
|
## Shown instead of the quiz when the Statistics tab is chosen.
|
||||||
|
##
|
||||||
|
## Covers are loaded for the cards on screen as they scroll into view, a few at a time,
|
||||||
|
## not for every soundtrack when the tab opens. See [CoverCache].
|
||||||
|
|
||||||
## Emitted when a card is clicked, to show all songs of [param soundtrack].
|
## Emitted when a card is clicked, to show all songs of [param soundtrack].
|
||||||
signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
|
signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
|
||||||
@@ -19,12 +22,18 @@ enum ShowFilter { ALL, PLAYED, NEVER_PLAYED }
|
|||||||
enum Mode { SOUNDTRACKS, SONGS }
|
enum Mode { SOUNDTRACKS, SONGS }
|
||||||
|
|
||||||
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
|
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
|
||||||
|
## How far outside the visible part cards still get their covers, so they are ready when
|
||||||
|
## scrolled to.
|
||||||
|
const COVER_MARGIN: float = 400.0
|
||||||
|
|
||||||
## The cards in the order they are shown, hidden ones included.
|
## The cards in the order they are shown, hidden ones included.
|
||||||
var cards: Array[SoundtrackCard] = []
|
var cards: Array[SoundtrackCard] = []
|
||||||
## Whether soundtracks or songs are shown. Change it with [method set_mode].
|
## Whether soundtracks or songs are shown. Change it with [method set_mode].
|
||||||
var mode: Mode = Mode.SOUNDTRACKS
|
var mode: Mode = Mode.SOUNDTRACKS
|
||||||
|
|
||||||
|
## The covers on disk, shared by the cards.
|
||||||
|
var covers: CoverCache = CoverCache.new()
|
||||||
|
|
||||||
var _search: SoundtrackSearch = SoundtrackSearch.new()
|
var _search: SoundtrackSearch = SoundtrackSearch.new()
|
||||||
var _loading: bool = false
|
var _loading: bool = false
|
||||||
# The soundtracks last loaded, by name
|
# The soundtracks last loaded, by name
|
||||||
@@ -61,6 +70,11 @@ func _ready() -> void:
|
|||||||
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
|
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
|
||||||
retry_button.pressed.connect(refresh)
|
retry_button.pressed.connect(refresh)
|
||||||
retry_button.visible = false
|
retry_button.visible = false
|
||||||
|
# The cards have moved by the end of the frame
|
||||||
|
card_scroll.get_v_scroll_bar().value_changed.connect(
|
||||||
|
func(_value: float) -> void: load_covers_on_screen.call_deferred())
|
||||||
|
card_scroll.resized.connect(load_covers_on_screen)
|
||||||
|
visibility_changed.connect(load_covers_on_screen)
|
||||||
|
|
||||||
|
|
||||||
## Loads the statistics from the server. The cards already shown stay until the new
|
## Loads the statistics from the server. The cards already shown stay until the new
|
||||||
@@ -108,6 +122,7 @@ func apply_sort() -> void:
|
|||||||
return _comes_before(a.soundtrack, b.soundtrack, order))
|
return _comes_before(a.soundtrack, b.soundtrack, order))
|
||||||
for i: int in cards.size():
|
for i: int in cards.size():
|
||||||
card_grid.move_child(cards[i], i)
|
card_grid.move_child(cards[i], i)
|
||||||
|
load_covers_on_screen.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
## Shows only the cards or songs that match the search text and the show menu.
|
## Shows only the cards or songs that match the search text and the show menu.
|
||||||
@@ -124,14 +139,20 @@ func apply_filter() -> void:
|
|||||||
var filter: ShowFilter = show_option.selected as ShowFilter
|
var filter: ShowFilter = show_option.selected as ShowFilter
|
||||||
var shown: int = 0
|
var shown: int = 0
|
||||||
for card: SoundtrackCard in cards:
|
for card: SoundtrackCard in cards:
|
||||||
|
var was_visible: bool = card.visible
|
||||||
card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \
|
card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \
|
||||||
&& _passes_filter(card.soundtrack, filter)
|
&& _passes_filter(card.soundtrack, filter)
|
||||||
if card.visible:
|
if card.visible:
|
||||||
shown += 1
|
shown += 1
|
||||||
|
# Cards that come back with the filter fade in
|
||||||
|
if !was_visible:
|
||||||
|
Anim.fade_in(card)
|
||||||
if shown == cards.size():
|
if shown == cards.size():
|
||||||
count_label.text = "%d soundtracks" % cards.size()
|
count_label.text = "%d soundtracks" % cards.size()
|
||||||
else:
|
else:
|
||||||
count_label.text = "%d of %d soundtracks" % [shown, cards.size()]
|
count_label.text = "%d of %d soundtracks" % [shown, cards.size()]
|
||||||
|
# The grid has moved the cards by the end of the frame
|
||||||
|
load_covers_on_screen.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
|
func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
|
||||||
@@ -145,19 +166,58 @@ func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
|
|||||||
cards.append(card)
|
cards.append(card)
|
||||||
while cards.size() > soundtracks.size():
|
while cards.size() > soundtracks.size():
|
||||||
cards.pop_back().free()
|
cards.pop_back().free()
|
||||||
var names: Array[String] = []
|
|
||||||
_soundtracks.clear()
|
_soundtracks.clear()
|
||||||
|
var cover_times: Dictionary = {}
|
||||||
for i: int in soundtracks.size():
|
for i: int in soundtracks.size():
|
||||||
cards[i].show_soundtrack(soundtracks[i])
|
cards[i].show_soundtrack(soundtracks[i])
|
||||||
names.append(soundtracks[i].soundtrack_name)
|
|
||||||
_soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i]
|
_soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i]
|
||||||
_search.set_names(names)
|
cover_times[soundtracks[i].soundtrack_uuid] = soundtracks[i].cover_modified
|
||||||
|
covers.keep_only(cover_times)
|
||||||
|
_update_search_names()
|
||||||
_songs_ready = false
|
_songs_ready = false
|
||||||
apply_sort()
|
apply_sort()
|
||||||
if mode == Mode.SOUNDTRACKS:
|
if mode == Mode.SOUNDTRACKS:
|
||||||
apply_filter()
|
apply_filter()
|
||||||
|
|
||||||
|
|
||||||
|
## Asks for the covers of the cards that are on screen, or nearly.
|
||||||
|
func load_covers_on_screen() -> void:
|
||||||
|
if !is_visible_in_tree() || mode != Mode.SOUNDTRACKS:
|
||||||
|
return
|
||||||
|
var view: Rect2 = card_scroll.get_global_rect().grow(COVER_MARGIN)
|
||||||
|
for card: SoundtrackCard in cards:
|
||||||
|
if card.visible && view.intersects(card.get_global_rect()):
|
||||||
|
card.load_cover(covers)
|
||||||
|
|
||||||
|
|
||||||
|
## Shows the new alternative names of the soundtrack [param updated] and finds it by
|
||||||
|
## them, without loading the statistics again.
|
||||||
|
func update_alternative_names(updated: Soundtrack) -> void:
|
||||||
|
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
|
||||||
|
if soundtrack.soundtrack_uuid == updated.uuid:
|
||||||
|
soundtrack.alternative_names = updated.alternative_names
|
||||||
|
_update_search_names()
|
||||||
|
apply_filter()
|
||||||
|
|
||||||
|
|
||||||
|
## Shows the new cover of the soundtrack [param uuid], with the cover time
|
||||||
|
## [param modified], or its initials when [param modified] is 0.
|
||||||
|
func update_cover(uuid: String, modified: int) -> void:
|
||||||
|
for card: SoundtrackCard in cards:
|
||||||
|
if card.soundtrack.soundtrack_uuid == uuid:
|
||||||
|
card.set_cover_time(modified)
|
||||||
|
load_covers_on_screen()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_search_names() -> void:
|
||||||
|
var names: Array[String] = []
|
||||||
|
var alternatives: Dictionary = {}
|
||||||
|
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
|
||||||
|
names.append(soundtrack.soundtrack_name)
|
||||||
|
alternatives[soundtrack.soundtrack_name] = soundtrack.alternative_names
|
||||||
|
_search.set_names(names, alternatives)
|
||||||
|
|
||||||
|
|
||||||
# Typing in the song list waits for a pause, since searching every song takes a moment
|
# Typing in the song list waits for a pause, since searching every song takes a moment
|
||||||
func _on_search_text_changed(_text: String) -> void:
|
func _on_search_text_changed(_text: String) -> void:
|
||||||
if mode == Mode.SONGS:
|
if mode == Mode.SONGS:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
+14420
-4456
File diff suppressed because it is too large.
Load diff
@@ -25,6 +25,15 @@ var requested_paths: Array[String] = []
|
|||||||
var requested_methods: Array[int] = []
|
var requested_methods: Array[int] = []
|
||||||
## Headers of each request, in the same order as [member requested_paths].
|
## Headers of each request, in the same order as [member requested_paths].
|
||||||
var requested_headers: Array[PackedStringArray] = []
|
var requested_headers: Array[PackedStringArray] = []
|
||||||
|
## The body of each request as text, e.g. its JSON, empty for none, in the same order as
|
||||||
|
## [member requested_paths].
|
||||||
|
var requested_bodies: Array[String] = []
|
||||||
|
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
|
||||||
|
## [member requested_paths].
|
||||||
|
var requested_raw_bodies: Array[PackedByteArray] = []
|
||||||
|
## Whether each request followed redirects, in the same order as
|
||||||
|
## [member requested_paths].
|
||||||
|
var requested_follow_redirects: Array[bool] = []
|
||||||
## How many tokens have been asked for.
|
## How many tokens have been asked for.
|
||||||
var token_requests: int = 0
|
var token_requests: int = 0
|
||||||
## The JSON body of the last token request.
|
## The JSON body of the last token request.
|
||||||
@@ -61,6 +70,14 @@ func uninstall() -> void:
|
|||||||
Settings.queue_ids = _previous_queue_ids
|
Settings.queue_ids = _previous_queue_ids
|
||||||
|
|
||||||
|
|
||||||
|
## Answers requests to [param path] with a redirect to [param location], like the
|
||||||
|
## server's download routes. Requests that follow redirects get an empty 200 instead, as
|
||||||
|
## if they had followed it.
|
||||||
|
func respond_redirect(path: String, location: String) -> void:
|
||||||
|
_responses[path] = {"code": 302, "body": PackedByteArray(), "location": location}
|
||||||
|
_successes_left.erase(path)
|
||||||
|
|
||||||
|
|
||||||
## Answers requests to [param path] with [param data] encoded as JSON.
|
## Answers requests to [param path] with [param data] encoded as JSON.
|
||||||
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
|
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
|
||||||
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
|
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
|
||||||
@@ -107,30 +124,35 @@ func request(
|
|||||||
method: int,
|
method: int,
|
||||||
on_complete: Callable,
|
on_complete: Callable,
|
||||||
headers: PackedStringArray = PackedStringArray(),
|
headers: PackedStringArray = PackedStringArray(),
|
||||||
body: String = "",
|
body: PackedByteArray = PackedByteArray(),
|
||||||
|
follow_redirects: bool = true,
|
||||||
) -> void:
|
) -> void:
|
||||||
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
|
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
|
||||||
if path == "/token" && method == HTTPClient.METHOD_POST:
|
if path == "/token" && method == HTTPClient.METHOD_POST:
|
||||||
token_requests += 1
|
token_requests += 1
|
||||||
if _held_paths.has(path):
|
if _held_paths.has(path):
|
||||||
_held_paths[path].append(_answer_token_request.bind(body, on_complete))
|
_held_paths[path].append(
|
||||||
|
_answer_token_request.bind(body.get_string_from_utf8(), on_complete))
|
||||||
return
|
return
|
||||||
_answer_token_request(body, on_complete)
|
_answer_token_request(body.get_string_from_utf8(), on_complete)
|
||||||
return
|
return
|
||||||
requested_paths.append(path)
|
requested_paths.append(path)
|
||||||
requested_methods.append(method)
|
requested_methods.append(method)
|
||||||
requested_headers.append(headers)
|
requested_headers.append(headers)
|
||||||
|
requested_bodies.append(body.get_string_from_utf8())
|
||||||
|
requested_raw_bodies.append(body)
|
||||||
|
requested_follow_redirects.append(follow_redirects)
|
||||||
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
||||||
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
|
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
|
||||||
PackedStringArray())
|
PackedStringArray())
|
||||||
return
|
return
|
||||||
if _held_paths.has(path):
|
if _held_paths.has(path):
|
||||||
_held_paths[path].append(_answer.bind(path, on_complete))
|
_held_paths[path].append(_answer.bind(path, on_complete, follow_redirects))
|
||||||
return
|
return
|
||||||
_answer(path, on_complete)
|
_answer(path, on_complete, follow_redirects)
|
||||||
|
|
||||||
|
|
||||||
func _answer(path: String, on_complete: Callable) -> void:
|
func _answer(path: String, on_complete: Callable, follow_redirects: bool = true) -> void:
|
||||||
var headers: PackedStringArray = []
|
var headers: PackedStringArray = []
|
||||||
if !queue_id.is_empty() && path != "/token":
|
if !queue_id.is_empty() && path != "/token":
|
||||||
headers.append("X-Queue-Id: " + queue_id)
|
headers.append("X-Queue-Id: " + queue_id)
|
||||||
@@ -139,7 +161,13 @@ func _answer(path: String, on_complete: Callable) -> void:
|
|||||||
on_complete.call(503, PackedByteArray(), headers)
|
on_complete.call(503, PackedByteArray(), headers)
|
||||||
return
|
return
|
||||||
_successes_left[path] -= 1
|
_successes_left[path] -= 1
|
||||||
if _responses.has(path):
|
if _responses.has(path) && _responses[path].has("location"):
|
||||||
|
if follow_redirects:
|
||||||
|
on_complete.call(200, PackedByteArray(), headers)
|
||||||
|
else:
|
||||||
|
headers.append("Location: " + _responses[path].location)
|
||||||
|
on_complete.call(302, PackedByteArray(), headers)
|
||||||
|
elif _responses.has(path):
|
||||||
on_complete.call(_responses[path].code, _responses[path].body, headers)
|
on_complete.call(_responses[path].code, _responses[path].body, headers)
|
||||||
else:
|
else:
|
||||||
on_complete.call(404, PackedByteArray(), headers)
|
on_complete.call(404, PackedByteArray(), headers)
|
||||||
|
|||||||
@@ -20,12 +20,18 @@ func before_test() -> void:
|
|||||||
Playlist.first_song_played = false
|
Playlist.first_song_played = false
|
||||||
Playlist.is_song_downloading = false
|
Playlist.is_song_downloading = false
|
||||||
Settings.player_array.clear()
|
Settings.player_array.clear()
|
||||||
|
Settings.music_folder = null
|
||||||
|
Settings.character_folder = ""
|
||||||
|
# Every test starts a new quiz, unless it saves one in _before_window_starts()
|
||||||
|
QuizSave.delete()
|
||||||
Settings.add_to_stats = true
|
Settings.add_to_stats = true
|
||||||
Settings.hide_next_track = true
|
Settings.hide_next_track = true
|
||||||
Settings.use_low_played_mode = false
|
Settings.random_mode = Settings.RandomMode.NORMAL
|
||||||
Settings.winning_score = 20
|
Settings.winning_score = 20
|
||||||
# Otherwise the About popup opens at start, as for a new version
|
# Otherwise the About popup opens at start, as for a new version
|
||||||
Settings.last_seen_version = Settings.version
|
Settings.last_seen_version = Settings.version
|
||||||
|
# Popups open and close at once, so the tests don't wait for animations
|
||||||
|
Settings.reduce_animations = true
|
||||||
|
|
||||||
_server = FakeHttpTransport.new().install()
|
_server = FakeHttpTransport.new().install()
|
||||||
var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
|
var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
|
||||||
@@ -44,6 +50,8 @@ func before_test() -> void:
|
|||||||
_runner = scene_runner("res://main/MainWindow.tscn")
|
_runner = scene_runner("res://main/MainWindow.tscn")
|
||||||
_window = _runner.scene()
|
_window = _runner.scene()
|
||||||
_quiz = _window.get_node("Quiz")
|
_quiz = _window.get_node("Quiz")
|
||||||
|
# Rows of buttons are laid out by their containers at the end of the first frame
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
|
||||||
|
|
||||||
## Override to change the fake server before the window starts and sends its first
|
## Override to change the fake server before the window starts and sends its first
|
||||||
@@ -55,6 +63,7 @@ func _before_window_starts() -> void:
|
|||||||
func after_test() -> void:
|
func after_test() -> void:
|
||||||
# Let nodes queued for deletion by the window be freed before orphans are counted
|
# Let nodes queued for deletion by the window be freed before orphans are counted
|
||||||
await await_idle_frame()
|
await await_idle_frame()
|
||||||
|
QuizSave.delete()
|
||||||
_server.uninstall()
|
_server.uninstall()
|
||||||
_snapshot.restore()
|
_snapshot.restore()
|
||||||
|
|
||||||
@@ -77,7 +86,7 @@ func _play_first_song() -> void:
|
|||||||
|
|
||||||
## Adds a player through the name field, the same way the user does.
|
## Adds a player through the name field, the same way the user does.
|
||||||
func _add_player(player_name: String) -> PlayerObject:
|
func _add_player(player_name: String) -> PlayerObject:
|
||||||
var name_field: TextEdit = _quiz.get_node(
|
var name_field: LineEdit = _quiz.get_node(
|
||||||
"PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField")
|
"PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField")
|
||||||
name_field.text = player_name
|
name_field.text = player_name
|
||||||
_quiz.players_panel.add_player()
|
_quiz.players_panel.add_player()
|
||||||
@@ -96,7 +105,7 @@ func _player_row(player_object: PlayerObject) -> PlayerRow:
|
|||||||
|
|
||||||
## Removes a player with the remove button in its row.
|
## Removes a player with the remove button in its row.
|
||||||
func _remove_player(player_object: PlayerObject) -> void:
|
func _remove_player(player_object: PlayerObject) -> void:
|
||||||
(_player_row(player_object).get_node("HBoxContainer/RemovePlayer") as BaseButton) \
|
(_player_row(player_object).get_node("%RemovePlayer") as BaseButton) \
|
||||||
.pressed.emit()
|
.pressed.emit()
|
||||||
await _runner.simulate_frames(1)
|
await _runner.simulate_frames(1)
|
||||||
|
|
||||||
|
|||||||
+63
-6
@@ -15,9 +15,10 @@ const SOUNDTRACKS: Array[String] = [
|
|||||||
"Donkey Kong Country 2", "Lufia II", "Illusion of Gaia",
|
"Donkey Kong Country 2", "Lufia II", "Illusion of Gaia",
|
||||||
]
|
]
|
||||||
const SONG_INFO: Dictionary = {
|
const SONG_INFO: Dictionary = {
|
||||||
"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time",
|
"SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "soundtrack-0",
|
||||||
|
"Song": "Corridors of Time",
|
||||||
}
|
}
|
||||||
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:00:02"}
|
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:02"}
|
||||||
const SYNC_RESULT: Dictionary = {
|
const SYNC_RESULT: Dictionary = {
|
||||||
"soundtracks_added": ["Terranigma", "Lufia II"],
|
"soundtracks_added": ["Terranigma", "Lufia II"],
|
||||||
"soundtracks_re_added": [],
|
"soundtracks_re_added": [],
|
||||||
@@ -25,7 +26,7 @@ const SYNC_RESULT: Dictionary = {
|
|||||||
"soundtracks_changed_content": ["Chrono Trigger"],
|
"soundtracks_changed_content": ["Chrono Trigger"],
|
||||||
"soundtracks_removed": ["Old Soundtrack"],
|
"soundtracks_removed": ["Old Soundtrack"],
|
||||||
"catched_errors": [],
|
"catched_errors": [],
|
||||||
"total_time": "00:00:03",
|
"total_time": "00:03",
|
||||||
}
|
}
|
||||||
|
|
||||||
## The server's version history, newest first.
|
## The server's version history, newest first.
|
||||||
@@ -48,6 +49,8 @@ const JAPANESE_SONGS: Array[String] = [
|
|||||||
|
|
||||||
## The character pictures the fake server hands out.
|
## The character pictures the fake server hands out.
|
||||||
const CHARACTERS_DIR: String = "res://characters"
|
const CHARACTERS_DIR: String = "res://characters"
|
||||||
|
## Small copies of covers from the MusicServer, 2:3 like all its covers.
|
||||||
|
const COVERS_DIR: String = "res://tests/data/covers"
|
||||||
|
|
||||||
## Popups in the order the tour opens them: the state name, then the popup's node. The
|
## Popups in the order the tour opens them: the state name, then the popup's node. The
|
||||||
## state names are what the popups were first called, so screenshots taken before they
|
## state names are what the popups were first called, so screenshots taken before they
|
||||||
@@ -91,6 +94,18 @@ static func make_server() -> FakeHttpTransport:
|
|||||||
server.respond_bytes("/sync", PackedByteArray())
|
server.respond_bytes("/sync", PackedByteArray())
|
||||||
server.respond_json("/sync/progress", SYNC_PROGRESS)
|
server.respond_json("/sync/progress", SYNC_PROGRESS)
|
||||||
server.respond_json("/statistics/soundtracks", soundtrack_statistics())
|
server.respond_json("/statistics/soundtracks", soundtrack_statistics())
|
||||||
|
# Real covers from the server, made smaller, for every other soundtrack
|
||||||
|
for i: int in range(0, SOUNDTRACKS.size(), 2):
|
||||||
|
server.respond_bytes("/soundtracks/soundtrack-%d/cover" % i,
|
||||||
|
FileAccess.get_file_as_bytes(COVERS_DIR.path_join("cover_%d.jpg" % (i >> 1))))
|
||||||
|
# The soundtrack list tells the quiz which soundtracks have covers
|
||||||
|
var soundtracks: Array[Dictionary] = []
|
||||||
|
for soundtrack: Dictionary in soundtrack_statistics():
|
||||||
|
soundtracks.append({"uuid": soundtrack.soundtrack_uuid,
|
||||||
|
"name": soundtrack.soundtrack_name,
|
||||||
|
"cover_modified": soundtrack.get("cover_modified", 0),
|
||||||
|
"alternative_names": soundtrack.get("alternative_names", [])})
|
||||||
|
server.respond_json("/soundtracks", soundtracks)
|
||||||
server.respond_json("/version/history", SERVER_HISTORY)
|
server.respond_json("/version/history", SERVER_HISTORY)
|
||||||
return server
|
return server
|
||||||
|
|
||||||
@@ -118,6 +133,11 @@ static func soundtrack_statistics() -> Array[Dictionary]:
|
|||||||
}
|
}
|
||||||
if played > 0:
|
if played > 0:
|
||||||
soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1)
|
soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1)
|
||||||
|
# Every other soundtrack has a cover, the rest show their initials
|
||||||
|
if i % 2 == 0:
|
||||||
|
soundtrack.cover_modified = 1700000000
|
||||||
|
if i == 0:
|
||||||
|
soundtrack.alternative_names = ["CT", "クロノ・トリガー"]
|
||||||
statistics.append(soundtrack)
|
statistics.append(soundtrack)
|
||||||
return statistics
|
return statistics
|
||||||
|
|
||||||
@@ -163,14 +183,20 @@ static func prepare_autoloads() -> void:
|
|||||||
Playlist.first_song_played = false
|
Playlist.first_song_played = false
|
||||||
Playlist.is_song_downloading = false
|
Playlist.is_song_downloading = false
|
||||||
Settings.player_array.clear()
|
Settings.player_array.clear()
|
||||||
|
Settings.music_folder = null
|
||||||
|
Settings.character_folder = ""
|
||||||
|
# A quiz saved by an earlier run would be continued
|
||||||
|
QuizSave.delete()
|
||||||
Settings.edit_players = false
|
Settings.edit_players = false
|
||||||
Settings.currently_syncing = false
|
Settings.currently_syncing = false
|
||||||
Settings.character_select_open = false
|
Settings.character_select_open = false
|
||||||
Settings.add_to_stats = true
|
Settings.add_to_stats = true
|
||||||
Settings.hide_next_track = true
|
Settings.hide_next_track = true
|
||||||
Settings.use_low_played_mode = false
|
Settings.random_mode = Settings.RandomMode.NORMAL
|
||||||
Settings.winning_score = 20
|
Settings.winning_score = 20
|
||||||
Settings.last_seen_version = Settings.version
|
Settings.last_seen_version = Settings.version
|
||||||
|
# The same picture every time, without half-finished animations
|
||||||
|
Settings.reduce_animations = true
|
||||||
# The inspiration list scrolls by itself, which would make every picture different
|
# The inspiration list scrolls by itself, which would make every picture different
|
||||||
Settings.inspiration_list_speed = 0
|
Settings.inspiration_list_speed = 0
|
||||||
|
|
||||||
@@ -198,7 +224,8 @@ func run(on_state: Callable) -> void:
|
|||||||
var picture: Image = Image.new()
|
var picture: Image = Image.new()
|
||||||
picture.load_png_from_buffer(
|
picture.load_png_from_buffer(
|
||||||
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_names()[0])))
|
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_names()[0])))
|
||||||
_player_row(0).new_character_selected(ImageTexture.create_from_image(picture))
|
_player_row(0).new_character_selected(ImageTexture.create_from_image(picture),
|
||||||
|
character_names()[0])
|
||||||
_player_row(0).add_point()
|
_player_row(0).add_point()
|
||||||
_player_row(0).add_point()
|
_player_row(0).add_point()
|
||||||
_player_row(1).add_point()
|
_player_row(1).add_point()
|
||||||
@@ -249,6 +276,27 @@ func run(on_state: Callable) -> void:
|
|||||||
top_bar.show_latest_version("99.10.10")
|
top_bar.show_latest_version("99.10.10")
|
||||||
await _settle()
|
await _settle()
|
||||||
await on_state.call("update_available", null)
|
await on_state.call("update_available", null)
|
||||||
|
var update_popup: UpdatePopup = _node("UpdatePopup")
|
||||||
|
var updater: Updater = update_popup.updater
|
||||||
|
updater.in_editor = false
|
||||||
|
updater.os_name = "Windows"
|
||||||
|
DirAccess.make_dir_recursive_absolute(Settings.config_dir())
|
||||||
|
updater.executable_path = Settings.config_dir().path_join("MusicPlayer.exe")
|
||||||
|
update_popup.show_update("99.10.10")
|
||||||
|
await _settle()
|
||||||
|
await on_state.call("popup_UpdateWindow", update_popup)
|
||||||
|
update_popup.download_progress.visible = true
|
||||||
|
update_popup._set_busy(true)
|
||||||
|
update_popup._show_status("")
|
||||||
|
update_popup._on_progress(31 << 20, 80 << 20)
|
||||||
|
await _settle()
|
||||||
|
await on_state.call("popup_UpdateWindow_downloading", update_popup)
|
||||||
|
updater.in_editor = true
|
||||||
|
update_popup.show_update("99.10.10")
|
||||||
|
await _settle()
|
||||||
|
await on_state.call("popup_UpdateWindow_browser_only", update_popup)
|
||||||
|
update_popup.close()
|
||||||
|
await _settle()
|
||||||
top_bar.show_latest_version("1.0.0")
|
top_bar.show_latest_version("1.0.0")
|
||||||
await _settle()
|
await _settle()
|
||||||
|
|
||||||
@@ -285,6 +333,15 @@ func run(on_state: Callable) -> void:
|
|||||||
break
|
break
|
||||||
await _settle()
|
await _settle()
|
||||||
await on_state.call("popup_SoundtrackStats", stats_popup)
|
await on_state.call("popup_SoundtrackStats", stats_popup)
|
||||||
|
stats_popup.edit_names()
|
||||||
|
stats_popup.new_name_field.text = "Chrono"
|
||||||
|
await _settle()
|
||||||
|
await on_state.call("popup_SoundtrackStats_names", stats_popup)
|
||||||
|
stats_popup.change_cover()
|
||||||
|
stats_popup.cover_id_field.text = "5248061"
|
||||||
|
await _settle()
|
||||||
|
await on_state.call("popup_SoundtrackStats_cover", stats_popup)
|
||||||
|
stats_popup.stop_editing()
|
||||||
stats_popup.close()
|
stats_popup.close()
|
||||||
_main.show_tab(&"quiz")
|
_main.show_tab(&"quiz")
|
||||||
await _settle()
|
await _settle()
|
||||||
@@ -430,7 +487,7 @@ func _settle() -> void:
|
|||||||
|
|
||||||
func _add_player(player_name: String) -> void:
|
func _add_player(player_name: String) -> void:
|
||||||
(_node("PlayersPanel").get_node("VBoxContainer/AddPlayerContainer/PlayerNameField") \
|
(_node("PlayersPanel").get_node("VBoxContainer/AddPlayerContainer/PlayerNameField") \
|
||||||
as TextEdit).text = player_name
|
as LineEdit).text = player_name
|
||||||
_call("add_player")
|
_call("add_player")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ func _before_window_starts() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func before_test() -> void:
|
func before_test() -> void:
|
||||||
super()
|
await super()
|
||||||
_popup = _window.get_node("AboutPopup")
|
_popup = _window.get_node("AboutPopup")
|
||||||
|
|
||||||
|
|
||||||
func _open_with_top_bar_button() -> void:
|
func _open_with_top_bar_button() -> void:
|
||||||
await _click_at((_window.get_node("TopBar/AboutButton") as Control).get_global_rect()
|
await _click_at((_window.get_node("TopBar/%AboutButton") as Control).get_global_rect()
|
||||||
.get_center())
|
.get_center())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
extends MainWindowTestSuite
|
||||||
|
## The animations, with Reduce animations off: popups, points, songs starting, the
|
||||||
|
## winner, filtering, the tabs and the buttons. The other tests run with animations off.
|
||||||
|
|
||||||
|
var _settings: SettingsPopup
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
await super()
|
||||||
|
Settings.reduce_animations = false
|
||||||
|
_settings = _window.get_node("SettingsPopup")
|
||||||
|
|
||||||
|
|
||||||
|
func _wait(seconds: float) -> void:
|
||||||
|
await await_millis(int(seconds * 1000) + 60)
|
||||||
|
|
||||||
|
|
||||||
|
func _layers(container: Node) -> Array[Node]:
|
||||||
|
return container.get_children().filter(func(node: Node) -> bool: return node is ColorRect)
|
||||||
|
|
||||||
|
|
||||||
|
func test_popup_fades_in_while_growing() -> void:
|
||||||
|
_settings.open()
|
||||||
|
|
||||||
|
assert_float(_settings.settings_panel.modulate.a).is_less(1.0)
|
||||||
|
assert_float(_settings.settings_panel.scale.x).is_less(1.0)
|
||||||
|
await _wait(Anim.NORMAL)
|
||||||
|
assert_float(_settings.settings_panel.modulate.a).is_equal(1.0)
|
||||||
|
assert_vector(_settings.settings_panel.scale).is_equal(Vector2.ONE)
|
||||||
|
|
||||||
|
|
||||||
|
func test_closing_popup_hides_after_fading_out() -> void:
|
||||||
|
_settings.open()
|
||||||
|
await _wait(Anim.NORMAL)
|
||||||
|
|
||||||
|
_settings.close()
|
||||||
|
|
||||||
|
assert_bool(_settings.visible).is_true()
|
||||||
|
assert_bool(_settings.is_closing).is_true()
|
||||||
|
await _wait(Anim.FAST)
|
||||||
|
assert_bool(_settings.visible).is_false()
|
||||||
|
assert_bool((_window.get_node("PopupBackdrop") as Control).visible).is_false()
|
||||||
|
# Ready to open again at full size
|
||||||
|
assert_float(_settings.settings_panel.modulate.a).is_equal(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_popup_opened_again_while_closing_stays_open() -> void:
|
||||||
|
_settings.open()
|
||||||
|
await _wait(Anim.NORMAL)
|
||||||
|
_settings.close()
|
||||||
|
|
||||||
|
_settings.open()
|
||||||
|
await _wait(Anim.NORMAL)
|
||||||
|
|
||||||
|
assert_bool(_settings.visible).is_true()
|
||||||
|
var backdrop: Control = _window.get_node("PopupBackdrop")
|
||||||
|
assert_bool(backdrop.visible).is_true()
|
||||||
|
assert_float(backdrop.modulate.a).is_equal(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_reduce_animations_opens_and_closes_at_once() -> void:
|
||||||
|
Settings.reduce_animations = true
|
||||||
|
|
||||||
|
_settings.open()
|
||||||
|
assert_float(_settings.settings_panel.modulate.a).is_equal(1.0)
|
||||||
|
_settings.close()
|
||||||
|
|
||||||
|
assert_bool(_settings.visible).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_reduce_animations_switch_is_saved() -> void:
|
||||||
|
# The switch shows the setting the window started with, which the tests turn on
|
||||||
|
assert_bool(_settings.reduce_animations_button.button_pressed).is_true()
|
||||||
|
|
||||||
|
_settings.reduce_animations_button.button_pressed = false
|
||||||
|
assert_bool(Settings.reduce_animations).is_false()
|
||||||
|
_settings.reduce_animations_button.button_pressed = true
|
||||||
|
|
||||||
|
assert_bool(Settings.reduce_animations).is_true()
|
||||||
|
var saved: ConfigFile = ConfigFile.new()
|
||||||
|
assert_int(saved.load(Settings.saved_settings_path())).is_equal(OK)
|
||||||
|
assert_bool(saved.get_value("app", "reduce_animations")).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_first_point_flashes_the_row_and_pops_the_score() -> void:
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _play_first_song()
|
||||||
|
var row: PlayerRow = _player_row(crono)
|
||||||
|
|
||||||
|
row.add_point()
|
||||||
|
|
||||||
|
assert_array(_layers(row)).has_size(1)
|
||||||
|
assert_float(row.score_label.scale.x).is_greater(1.0)
|
||||||
|
await _wait(Anim.SLOW * 2)
|
||||||
|
assert_array(_layers(row)).is_empty()
|
||||||
|
assert_vector(row.score_label.scale).is_equal(Vector2.ONE)
|
||||||
|
|
||||||
|
|
||||||
|
func test_later_points_only_pop_the_score() -> void:
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _play_first_song()
|
||||||
|
var row: PlayerRow = _player_row(crono)
|
||||||
|
row.add_point()
|
||||||
|
await _wait(Anim.SLOW * 2)
|
||||||
|
|
||||||
|
row.add_point()
|
||||||
|
|
||||||
|
assert_array(_layers(row)).is_empty()
|
||||||
|
assert_float(row.score_label.scale.x).is_greater(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_match_point_glows_and_says_so_until_it_changes() -> void:
|
||||||
|
Settings.winning_score = 3
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _play_first_song()
|
||||||
|
var row: PlayerRow = _player_row(crono)
|
||||||
|
|
||||||
|
row.add_point()
|
||||||
|
row.add_point()
|
||||||
|
|
||||||
|
assert_bool(row.match_point_tag.visible).is_true()
|
||||||
|
assert_object(row.get_node_or_null("Pulse")).is_not_null()
|
||||||
|
|
||||||
|
row.minus_point()
|
||||||
|
|
||||||
|
assert_bool(row.match_point_tag.visible).is_false()
|
||||||
|
assert_object(row.get_node_or_null("Pulse")).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_match_point_glows_still_with_reduced_animations() -> void:
|
||||||
|
Settings.reduce_animations = true
|
||||||
|
Settings.winning_score = 2
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _play_first_song()
|
||||||
|
|
||||||
|
_player_row(crono).add_point()
|
||||||
|
|
||||||
|
assert_object(_player_row(crono).get_node_or_null("Pulse")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_song_start_fades_in_the_title_and_flashes_its_row() -> void:
|
||||||
|
Settings.hide_next_track = false
|
||||||
|
# The next song is downloaded while the first plays
|
||||||
|
_next_song_is({"SongNo": 1, "Soundtrack": "Final Fantasy VI", "Song": "Dancing Mad"})
|
||||||
|
await _play_first_song()
|
||||||
|
await _wait(Anim.SLOW)
|
||||||
|
|
||||||
|
_press("SongControls/NextButton")
|
||||||
|
await _wait(0.55)
|
||||||
|
|
||||||
|
var now_playing: Control = _quiz.get_node("NowPlaying")
|
||||||
|
assert_float(now_playing.modulate.a).is_less(1.0)
|
||||||
|
var rows: Array[Node] = _quiz.music_list_panel.get_node("%MusicList").get_children()
|
||||||
|
var flashing: Array = rows.filter(func(row: Node) -> bool: return !_layers(row).is_empty())
|
||||||
|
assert_array(flashing).is_not_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func test_winner_gets_confetti_and_a_graph_drawn_from_the_left() -> void:
|
||||||
|
Settings.winning_score = 1
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _play_first_song()
|
||||||
|
|
||||||
|
_player_row(crono).add_point()
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
|
||||||
|
var winner: WinnerPopup = _quiz.winner_popup
|
||||||
|
assert_bool(winner.visible).is_true()
|
||||||
|
assert_object(winner.winner_panel.get_node_or_null("Confetti")).is_not_null()
|
||||||
|
assert_float(winner.score_graph.progress).is_less(1.0)
|
||||||
|
await _wait(1.3)
|
||||||
|
assert_float(winner.score_graph.progress).is_equal(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_graph_lines_are_cut_where_the_drawing_has_reached() -> void:
|
||||||
|
var points: PackedVector2Array = [Vector2(0, 0), Vector2(10, 0), Vector2(10, 10),
|
||||||
|
Vector2(30, 10)]
|
||||||
|
|
||||||
|
assert_array(Array(ScoreGraph.clip_line(points, 20))) \
|
||||||
|
.contains_exactly([Vector2(0, 0), Vector2(10, 0), Vector2(10, 10), Vector2(20, 10)])
|
||||||
|
assert_array(Array(ScoreGraph.clip_line(points, 100))).has_size(4)
|
||||||
|
assert_array(Array(ScoreGraph.clip_line(points, 5))) \
|
||||||
|
.contains_exactly([Vector2(0, 0), Vector2(5, 0)])
|
||||||
|
|
||||||
|
|
||||||
|
func test_statistics_tab_slides_in_and_ends_in_place() -> void:
|
||||||
|
var statistics: Control = _window.get_node("StatisticsView")
|
||||||
|
var home: Vector2 = statistics.position
|
||||||
|
|
||||||
|
_window.show_tab(&"statistics")
|
||||||
|
|
||||||
|
assert_float(statistics.position.x).is_greater(home.x)
|
||||||
|
await _wait(Anim.NORMAL)
|
||||||
|
assert_vector(statistics.position).is_equal(home)
|
||||||
|
assert_float(statistics.modulate.a).is_equal(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_every_button_bounces() -> void:
|
||||||
|
var sync: Button = _window.get_node("TopBar/%SyncButton")
|
||||||
|
var reset: Button = _quiz.get_node("QuizToolbar/ResetPointsButton")
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
|
||||||
|
for button: BaseButton in [sync, reset, _player_row(crono).add_button]:
|
||||||
|
assert_bool(button.has_meta(&"button_bounce_added")).override_failure_message(
|
||||||
|
button.name).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_helpers_do_nothing_with_reduced_animations() -> void:
|
||||||
|
Settings.reduce_animations = true
|
||||||
|
var label: Label = _quiz.get_node("NowPlaying/Titles/SongLabel")
|
||||||
|
|
||||||
|
Anim.pop(label)
|
||||||
|
Anim.fade_in(label)
|
||||||
|
|
||||||
|
assert_vector(label.scale).is_equal(Vector2.ONE)
|
||||||
|
assert_float(label.modulate.a).is_equal(1.0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cuukc27kr8360
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
extends MainWindowTestSuite
|
||||||
|
## Characters from a folder on this computer: one picked in the settings, or the
|
||||||
|
## characters folder of the music folder played. Players pick again when the characters
|
||||||
|
## change.
|
||||||
|
|
||||||
|
var _settings: SettingsPopup
|
||||||
|
var _mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
|
||||||
|
|
||||||
|
|
||||||
|
func _before_window_starts() -> void:
|
||||||
|
var png: PackedByteArray = Image.create(8, 4, false, Image.FORMAT_RGB8).save_png_to_buffer()
|
||||||
|
_server.respond_json("/character/list", ["Crono.png", "Frog.png"])
|
||||||
|
_server.respond_bytes("/character?name=Crono.png", png)
|
||||||
|
_server.respond_bytes("/character?name=Frog.png", png)
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
await super()
|
||||||
|
_settings = _window.settings_popup
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
|
||||||
|
|
||||||
|
func _folder(pictures: Array[String], songs: Array[String] = []) -> String:
|
||||||
|
var root: String = create_temp_dir("character_folders_%d" % randi())
|
||||||
|
for file_name: String in pictures:
|
||||||
|
var path: String = root.path_join(file_name)
|
||||||
|
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||||
|
FileAccess.open(path, FileAccess.WRITE).store_buffer(
|
||||||
|
Image.create(16, 8, false, Image.FORMAT_RGB8).save_png_to_buffer())
|
||||||
|
for file_name: String in songs:
|
||||||
|
FileAccess.open(root.path_join(file_name), FileAccess.WRITE).store_buffer(_mp3)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
func _card_names() -> Array[String]:
|
||||||
|
var names: Array[String] = []
|
||||||
|
for card: CharacterCard in _quiz.character_select_popup.character_grid.get_children():
|
||||||
|
if !card.is_queued_for_deletion():
|
||||||
|
names.append(card.character_name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
func _pick(player: PlayerObject, character_name: String) -> void:
|
||||||
|
_player_row(player).change_character()
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
for card: CharacterCard in _quiz.character_select_popup.character_grid.get_children():
|
||||||
|
if card.character_name == character_name:
|
||||||
|
await _click_at(card.get_global_rect().get_center())
|
||||||
|
return
|
||||||
|
fail("No card for " + character_name)
|
||||||
|
|
||||||
|
|
||||||
|
func test_server_characters_are_shown_at_first() -> void:
|
||||||
|
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
|
||||||
|
assert_str(_settings.characters_label.text).is_equal("From the server")
|
||||||
|
assert_bool(_settings.use_server_characters_button.visible).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_picking_a_folder_shows_its_characters() -> void:
|
||||||
|
var folder: String = _folder(["Santa.png", "Elf.jpg"])
|
||||||
|
|
||||||
|
_settings.use_character_folder(folder)
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
|
||||||
|
assert_array(_card_names()).contains_exactly(["Elf.jpg", "Santa.png"])
|
||||||
|
assert_str(_settings.characters_label.text).is_equal(folder.get_file())
|
||||||
|
assert_bool(_settings.use_server_characters_button.visible).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_character_from_a_folder_is_picked_with_its_picture_and_name() -> void:
|
||||||
|
_settings.use_character_folder(_folder(["Santa.png"]))
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
|
||||||
|
await _pick(crono, "Santa.png")
|
||||||
|
|
||||||
|
assert_str(crono.character_name).is_equal("Santa.png")
|
||||||
|
assert_vector(crono.character.get_size()).is_equal(Vector2(16, 8))
|
||||||
|
|
||||||
|
|
||||||
|
func test_picking_a_folder_resets_the_players_characters() -> void:
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _pick(crono, "Frog.png")
|
||||||
|
assert_str(crono.character_name).is_equal("Frog.png")
|
||||||
|
|
||||||
|
_settings.use_character_folder(_folder(["Santa.png"]))
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
|
||||||
|
assert_str(crono.character_name).is_empty()
|
||||||
|
assert_str(crono.character.resource_path).is_equal(PlayerObject.NO_CHARACTER_PATH)
|
||||||
|
assert_object(_player_row(crono).character.texture_normal).is_same(crono.character)
|
||||||
|
|
||||||
|
|
||||||
|
func test_folder_without_pictures_is_not_used() -> void:
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _pick(crono, "Frog.png")
|
||||||
|
|
||||||
|
_settings.use_character_folder(_folder([], ["01. Song.mp3"]))
|
||||||
|
|
||||||
|
assert_bool(_settings.characters_error_label.visible).is_true()
|
||||||
|
assert_str(_settings.characters_error_label.text).starts_with("No pictures found in")
|
||||||
|
assert_str(Settings.character_folder).is_empty()
|
||||||
|
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
|
||||||
|
assert_str(crono.character_name).is_equal("Frog.png")
|
||||||
|
|
||||||
|
|
||||||
|
func test_use_server_goes_back_to_the_server_characters() -> void:
|
||||||
|
_settings.use_character_folder(_folder(["Santa.png"]))
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _pick(crono, "Santa.png")
|
||||||
|
|
||||||
|
_settings.use_server_characters()
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
|
||||||
|
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
|
||||||
|
assert_str(_settings.characters_label.text).is_equal("From the server")
|
||||||
|
assert_bool(_settings.use_server_characters_button.visible).is_false()
|
||||||
|
assert_str(crono.character_name).is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func test_music_folder_with_characters_brings_its_own() -> void:
|
||||||
|
var folder: String = _folder(["characters/Santa.png", "characters/Elf.png"],
|
||||||
|
["01. Jingle Bells.mp3"])
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _pick(crono, "Frog.png")
|
||||||
|
|
||||||
|
_settings.use_music_folder(folder)
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
|
||||||
|
assert_array(_card_names()).contains_exactly(["Elf.png", "Santa.png"])
|
||||||
|
assert_str(_settings.characters_label.text).is_equal("From " + folder.get_file())
|
||||||
|
assert_str(crono.character_name).is_empty()
|
||||||
|
# A prepared quiz with characters is still a prepared quiz
|
||||||
|
assert_bool(Settings.is_prepared_quiz()).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_music_folder_without_characters_keeps_the_server_characters() -> void:
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _pick(crono, "Frog.png")
|
||||||
|
|
||||||
|
_settings.use_music_folder(_folder([], ["01. Song.mp3"]))
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
|
||||||
|
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
|
||||||
|
assert_str(crono.character_name).is_equal("Frog.png")
|
||||||
|
|
||||||
|
|
||||||
|
func test_leaving_a_music_folder_with_characters_goes_back_to_the_server() -> void:
|
||||||
|
_settings.use_music_folder(_folder(["characters/Santa.png"], ["01. Song.mp3"]))
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _pick(crono, "Santa.png")
|
||||||
|
|
||||||
|
_settings.use_server()
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
|
||||||
|
assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"])
|
||||||
|
assert_str(crono.character_name).is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func test_picked_folder_is_used_before_the_music_folders_characters() -> void:
|
||||||
|
var picked: String = _folder(["Snowman.png"])
|
||||||
|
_settings.use_character_folder(picked)
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
|
||||||
|
_settings.use_music_folder(_folder(["characters/Santa.png"], ["01. Song.mp3"]))
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
|
||||||
|
assert_array(_card_names()).contains_exactly(["Snowman.png"])
|
||||||
|
assert_str(_settings.characters_label.text).is_equal(picked.get_file())
|
||||||
|
|
||||||
|
|
||||||
|
func test_loading_the_same_characters_again_keeps_the_players_characters() -> void:
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
await _pick(crono, "Frog.png")
|
||||||
|
|
||||||
|
# A sync reloads the characters from the same server
|
||||||
|
_quiz.on_sync_finished()
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
|
||||||
|
assert_str(crono.character_name).is_equal("Frog.png")
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_characters_folder_is_kept_for_this_session_only() -> void:
|
||||||
|
_settings.use_character_folder(_folder(["Santa.png"]))
|
||||||
|
Settings.save_settings()
|
||||||
|
|
||||||
|
var saved: ConfigFile = ConfigFile.new()
|
||||||
|
saved.load(Settings.saved_settings_path())
|
||||||
|
|
||||||
|
for section: String in saved.get_sections():
|
||||||
|
assert_bool(saved.has_section_key(section, "character_folder")).is_false()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dcjuwndgpwled
|
||||||
@@ -21,10 +21,11 @@ func _before_window_starts() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func before_test() -> void:
|
func before_test() -> void:
|
||||||
super()
|
await super()
|
||||||
_picked.clear()
|
_picked.clear()
|
||||||
_popup = _quiz.get_node("CharacterSelectPopup")
|
_popup = _quiz.get_node("CharacterSelectPopup")
|
||||||
_popup.character_selected.connect(func(texture: Texture2D) -> void: _picked.append(texture))
|
_popup.character_selected.connect(
|
||||||
|
func(texture: Texture2D, _name: String) -> void: _picked.append(texture))
|
||||||
# Opened from the player's row, like clicking the player's picture
|
# Opened from the player's row, like clicking the player's picture
|
||||||
_crono = _add_player("Crono")
|
_crono = _add_player("Crono")
|
||||||
_player_row(_crono).change_character()
|
_player_row(_crono).change_character()
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
extends MainWindowTestSuite
|
||||||
|
## Clear cache in the settings: deletes the downloaded character pictures, covers and
|
||||||
|
## songs, but not the songs the quiz uses, the settings or the saved quiz.
|
||||||
|
|
||||||
|
var _popup: SettingsPopup
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
# The cache is kept next to the settings, which the tests keep in a folder of their own
|
||||||
|
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("clear_cache_%d" % randi()))
|
||||||
|
await super()
|
||||||
|
_popup = _window.get_node("SettingsPopup")
|
||||||
|
|
||||||
|
|
||||||
|
func _write(path: String, size: int) -> void:
|
||||||
|
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||||
|
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
var bytes: PackedByteArray = PackedByteArray()
|
||||||
|
bytes.resize(size)
|
||||||
|
file.store_buffer(bytes)
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
|
||||||
|
func _files_in(dir: String) -> PackedStringArray:
|
||||||
|
return DirAccess.get_files_at(dir) if DirAccess.dir_exists_absolute(dir) \
|
||||||
|
else PackedStringArray()
|
||||||
|
|
||||||
|
|
||||||
|
func _fill_cache() -> String:
|
||||||
|
_write(CharacterCache.folder().path_join("Crono.png"), 3000)
|
||||||
|
_write(CoverCache.folder().path_join("st-1"), 2000)
|
||||||
|
var old_song: String = Playlist.songs_folder().path_join("old.mp3")
|
||||||
|
_write(old_song, 5000)
|
||||||
|
return old_song
|
||||||
|
|
||||||
|
|
||||||
|
func test_shows_how_much_the_cache_takes() -> void:
|
||||||
|
_fill_cache()
|
||||||
|
|
||||||
|
_popup.open()
|
||||||
|
|
||||||
|
assert_str(_popup.cache_size_label.text).is_equal(
|
||||||
|
SettingsPopup.size_text(SettingsPopup.cache_size()))
|
||||||
|
assert_int(SettingsPopup.cache_size()).is_greater_equal(10000)
|
||||||
|
assert_bool(_popup.clear_cache_button.disabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_clear_cache_deletes_pictures_covers_and_old_songs() -> void:
|
||||||
|
var old_song: String = _fill_cache()
|
||||||
|
var playing: PackedStringArray = Playlist.song_files_in_use()
|
||||||
|
assert_array(playing).is_not_empty()
|
||||||
|
var lists_before: int = _server.count("/character/list")
|
||||||
|
_popup.open()
|
||||||
|
|
||||||
|
_popup.clear_cache()
|
||||||
|
|
||||||
|
assert_array(_files_in(CharacterCache.folder())).is_empty()
|
||||||
|
assert_array(_files_in(CoverCache.folder())).is_empty()
|
||||||
|
assert_bool(FileAccess.file_exists(old_song)).is_false()
|
||||||
|
# The quiz's songs stay, so they can still be played
|
||||||
|
for path: String in playing:
|
||||||
|
assert_bool(FileAccess.file_exists(path)).is_true()
|
||||||
|
assert_str(_popup.cache_size_label.text).starts_with("Cleared ")
|
||||||
|
assert_bool(_popup.clear_cache_button.disabled).is_true()
|
||||||
|
# The characters are loaded again at once
|
||||||
|
assert_int(_server.count("/character/list")).is_greater(lists_before)
|
||||||
|
|
||||||
|
|
||||||
|
func test_clear_cache_keeps_the_settings_and_the_saved_quiz() -> void:
|
||||||
|
Settings.save_settings()
|
||||||
|
_quiz.save_soon()
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
_popup.open()
|
||||||
|
|
||||||
|
_popup.clear_cache()
|
||||||
|
|
||||||
|
assert_bool(FileAccess.file_exists(Settings.saved_settings_path())).is_true()
|
||||||
|
assert_bool(FileAccess.file_exists(QuizSave.path())).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_nothing_to_clear() -> void:
|
||||||
|
for dir: String in [CharacterCache.folder(), CoverCache.folder()]:
|
||||||
|
for file_name: String in _files_in(dir):
|
||||||
|
DirAccess.remove_absolute(dir.path_join(file_name))
|
||||||
|
Playlist.clear_song_files(Playlist.song_files_in_use())
|
||||||
|
|
||||||
|
_popup.open()
|
||||||
|
|
||||||
|
assert_str(_popup.cache_size_label.text).is_equal("Nothing")
|
||||||
|
assert_bool(_popup.clear_cache_button.disabled).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
@warning_ignore("unused_parameter")
|
||||||
|
func test_size_text(bytes: int, text: String, test_parameters: Array = [
|
||||||
|
[0, "Nothing"],
|
||||||
|
[1, "1 KB"],
|
||||||
|
[350 * 1024, "350 KB"],
|
||||||
|
[int(14.2 * 1024 * 1024), "14.2 MB"],
|
||||||
|
]) -> void:
|
||||||
|
assert_str(SettingsPopup.size_text(bytes)).is_equal(text)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://da5wh1me3wncq
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
extends MainWindowTestSuite
|
||||||
|
## The quiz is saved after every change and goes on where it was when the game starts
|
||||||
|
## again: players, points, characters and songs, from the server or a music folder. A new
|
||||||
|
## game, a changed server playlist or a file that can't be read start over.
|
||||||
|
|
||||||
|
const SECOND_SONG: Dictionary = {"SongNo": 1, "Soundtrack": "Final Fantasy VI",
|
||||||
|
"Song": "Dancing Mad"}
|
||||||
|
|
||||||
|
var _mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
|
||||||
|
|
||||||
|
|
||||||
|
func _saved() -> QuizSave:
|
||||||
|
# Saving waits until the end of the frame
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
return QuizSave.read()
|
||||||
|
|
||||||
|
|
||||||
|
## Closes the game and starts it again, like the user does: everything in memory is gone
|
||||||
|
## and only the files are left. The server answers with [param server_songs] as its
|
||||||
|
## playlist, the songs it handed out before.
|
||||||
|
func _restart_game(server_songs: Array = SONG_INFO) -> void:
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
_window.free()
|
||||||
|
Settings.player_array.clear()
|
||||||
|
Settings.music_folder = null
|
||||||
|
Settings.character_folder = ""
|
||||||
|
Settings.add_to_stats = true
|
||||||
|
Playlist.song_object_array.clear()
|
||||||
|
Playlist.currently_playing_song = -1
|
||||||
|
Playlist.latest_played_song = 0
|
||||||
|
Playlist.game_start_song = 0
|
||||||
|
Playlist.first_song_played = false
|
||||||
|
Playlist.is_song_downloading = false
|
||||||
|
Playlist._song_files_written = 0
|
||||||
|
_server.respond_json("/music/list", server_songs)
|
||||||
|
_server.requested_paths.clear()
|
||||||
|
_runner = scene_runner("res://main/MainWindow.tscn")
|
||||||
|
_window = _runner.scene()
|
||||||
|
_quiz = _window.get_node("Quiz")
|
||||||
|
await _runner.simulate_frames(3)
|
||||||
|
|
||||||
|
|
||||||
|
# Crono and Marle play the first song; Crono gets the point and the answer is shown. The
|
||||||
|
# second song is downloaded ahead.
|
||||||
|
func _play_a_little() -> Array[PlayerObject]:
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
var marle: PlayerObject = _add_player("Marle")
|
||||||
|
_next_song_is(SECOND_SONG)
|
||||||
|
await _play_first_song()
|
||||||
|
await _give_point_with_keybind(1)
|
||||||
|
_press("SongControls/ShowAnswerButton")
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
return [crono, marle]
|
||||||
|
|
||||||
|
|
||||||
|
func _now_playing() -> Array[String]:
|
||||||
|
var now_playing: Node = _quiz.get_node("NowPlaying")
|
||||||
|
return [(now_playing.get_node("Titles/SoundtrackLabel") as Label).text,
|
||||||
|
(now_playing.get_node("Titles/SongLabel") as Label).text]
|
||||||
|
|
||||||
|
|
||||||
|
# Saving
|
||||||
|
|
||||||
|
func test_players_points_and_songs_are_saved_as_the_quiz_goes_on() -> void:
|
||||||
|
var players: Array[PlayerObject] = await _play_a_little()
|
||||||
|
|
||||||
|
var saved: QuizSave = await _saved()
|
||||||
|
|
||||||
|
assert_array(saved.players.map(func(p: PlayerObject) -> String: return p.player_name)) \
|
||||||
|
.contains_exactly(["Crono", "Marle"])
|
||||||
|
assert_int(saved.players[0].player_score).is_equal(1)
|
||||||
|
assert_int(saved.songs.size()).is_equal(2)
|
||||||
|
assert_array(saved.songs[0].players_given_point).contains_exactly([players[0].id])
|
||||||
|
assert_bool(saved.songs[0].is_answered).is_true()
|
||||||
|
assert_bool(saved.songs[0].is_playing).is_true()
|
||||||
|
assert_bool(saved.songs[1].has_played).is_false()
|
||||||
|
assert_int(saved.currently_playing_song).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_taking_a_point_is_saved() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
|
||||||
|
await _take_point_with_keybind(1)
|
||||||
|
|
||||||
|
var saved: QuizSave = await _saved()
|
||||||
|
assert_int(saved.players[0].player_score).is_equal(0)
|
||||||
|
assert_array(saved.songs[0].players_given_point).is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func test_picking_a_character_is_saved() -> void:
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
_player_row(crono).change_character()
|
||||||
|
var texture: ImageTexture = ImageTexture.create_from_image(
|
||||||
|
Image.create(4, 4, false, Image.FORMAT_RGB8))
|
||||||
|
|
||||||
|
_quiz.character_select_popup.character_selected.emit(texture, "Frog.png")
|
||||||
|
|
||||||
|
assert_str((await _saved()).players[0].character_name).is_equal("Frog.png")
|
||||||
|
|
||||||
|
|
||||||
|
func test_removing_a_player_is_saved() -> void:
|
||||||
|
var players: Array[PlayerObject] = await _play_a_little()
|
||||||
|
|
||||||
|
await _remove_player(players[0])
|
||||||
|
|
||||||
|
var saved: QuizSave = await _saved()
|
||||||
|
assert_int(saved.players.size()).is_equal(1)
|
||||||
|
assert_array(saved.songs[0].players_given_point).is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func test_reset_points_saves_a_new_game() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
|
||||||
|
_press("QuizToolbar/ResetPointsButton")
|
||||||
|
|
||||||
|
var saved: QuizSave = await _saved()
|
||||||
|
assert_int(saved.players.size()).is_equal(2)
|
||||||
|
assert_int(saved.players[0].player_score).is_equal(0)
|
||||||
|
assert_array(saved.songs[0].players_given_point).is_empty()
|
||||||
|
assert_int(saved.game_start_song).is_equal(1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_sync_saves_a_new_game() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
|
||||||
|
_quiz.on_sync_finished()
|
||||||
|
await _runner.simulate_frames(3)
|
||||||
|
|
||||||
|
var saved: QuizSave = await _saved()
|
||||||
|
assert_int(saved.players[0].player_score).is_equal(0)
|
||||||
|
assert_str(saved.players[0].player_name).is_equal("Crono")
|
||||||
|
|
||||||
|
|
||||||
|
func test_winning_is_saved_with_statistics_off() -> void:
|
||||||
|
Settings.winning_score = 1
|
||||||
|
await _play_a_little()
|
||||||
|
|
||||||
|
assert_bool((await _saved()).stats_paused_by_win).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# Continuing after a restart
|
||||||
|
|
||||||
|
func test_quiz_goes_on_after_a_restart() -> void:
|
||||||
|
var before: Array[PlayerObject] = await _play_a_little()
|
||||||
|
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
assert_int(Settings.player_array.size()).is_equal(2)
|
||||||
|
var crono: PlayerObject = Settings.player_array[0]
|
||||||
|
assert_str(crono.player_name).is_equal("Crono")
|
||||||
|
assert_str(crono.id).is_equal(before[0].id)
|
||||||
|
assert_int(crono.player_score).is_equal(1)
|
||||||
|
assert_bool(crono.is_first_point).is_false()
|
||||||
|
assert_int(Playlist.song_object_array.size()).is_equal(2)
|
||||||
|
assert_array(Playlist.song_object_array[0].players_given_point).contains_exactly([crono.id])
|
||||||
|
assert_bool(Playlist.song_object_array[0].is_answered).is_true()
|
||||||
|
assert_bool(Playlist.song_object_array[1].has_played).is_false()
|
||||||
|
assert_int(Playlist.currently_playing_song).is_equal(0)
|
||||||
|
assert_array(_now_playing()).contains_exactly(["Chrono Trigger", "Corridors of Time"])
|
||||||
|
# The server's playlist was kept, not reset
|
||||||
|
assert_int(_server.count("/music/reset")).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_players_rows_show_the_saved_scores() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
var row: PlayerRow = _player_row(Settings.player_array[0])
|
||||||
|
assert_str(row.player_name_field.text).is_equal("Crono")
|
||||||
|
assert_str(row.score_label.text).is_equal("1")
|
||||||
|
|
||||||
|
|
||||||
|
func test_song_downloaded_ahead_is_kept_and_not_downloaded_again() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
var file: String = (await _saved()).songs[1].song_path
|
||||||
|
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
assert_bool(FileAccess.file_exists(file)).is_true()
|
||||||
|
assert_int(_server.count("/music/rand")).is_equal(0)
|
||||||
|
assert_bool((_quiz.get_node("SongControls/NextButton") as Button).disabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_next_plays_the_song_downloaded_before_the_restart() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
_press("SongControls/NextButton")
|
||||||
|
await await_millis(700)
|
||||||
|
|
||||||
|
assert_int(Playlist.currently_playing_song).is_equal(1)
|
||||||
|
assert_bool(_quiz.music_player.audio_player.playing).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_song_that_was_playing_is_ready_to_play_again() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
var player: MusicPlayer = _quiz.music_player
|
||||||
|
|
||||||
|
assert_object(player.audio_player.stream).is_not_null()
|
||||||
|
assert_bool(player.audio_player.playing).is_false()
|
||||||
|
assert_bool(player.shows_playing()).is_false()
|
||||||
|
|
||||||
|
player.play_or_pause()
|
||||||
|
|
||||||
|
assert_bool(player.audio_player.playing).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_keybinds_give_points_after_a_restart() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
await _give_point_with_keybind(2)
|
||||||
|
|
||||||
|
assert_array(_scores()).contains_exactly([1, 1])
|
||||||
|
assert_int((await _saved()).players[1].player_score).is_equal(1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_song_files_that_are_gone_are_downloaded_again() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
var saved: QuizSave = await _saved()
|
||||||
|
for song_object: SongObject in saved.songs:
|
||||||
|
DirAccess.remove_absolute(song_object.song_path)
|
||||||
|
_server.respond_bytes("/music?song=0", _mp3)
|
||||||
|
_server.respond_bytes("/music?song=1", _mp3)
|
||||||
|
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
# The song that was playing and the one downloaded ahead
|
||||||
|
assert_int(_server.count("/music?song=0")).is_equal(1)
|
||||||
|
assert_int(_server.count("/music?song=1")).is_equal(1)
|
||||||
|
assert_object(Playlist.get_song(1)).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_unused_song_files_are_deleted_at_start() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
var stray: String = Playlist.songs_folder().path_join("left_over.mp3")
|
||||||
|
FileAccess.open(stray, FileAccess.WRITE).store_buffer(_mp3)
|
||||||
|
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
assert_bool(FileAccess.file_exists(stray)).is_false()
|
||||||
|
assert_int(DirAccess.get_files_at(Playlist.songs_folder()).size()).is_equal(2)
|
||||||
|
|
||||||
|
|
||||||
|
func test_changed_server_playlist_starts_over_with_the_same_players() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
|
||||||
|
# The server's playlist was reset, e.g. by a sync from another computer
|
||||||
|
await _restart_game([{"SongNo": 0, "Soundtrack": "Terranigma", "Song": "Underworld"}])
|
||||||
|
|
||||||
|
assert_array(Settings.player_array.map(func(p: PlayerObject) -> String:
|
||||||
|
return p.player_name)).contains_exactly(["Crono", "Marle"])
|
||||||
|
assert_array(_scores()).contains_exactly([0, 0])
|
||||||
|
assert_str(Playlist.song_object_array[0].soundtrack_title).is_equal("Terranigma")
|
||||||
|
assert_bool(Playlist.song_object_array[0].has_played).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_songs_the_server_added_after_the_save_are_kept_as_played() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
var third: Dictionary = {"SongNo": 2, "Soundtrack": "Terranigma", "Song": "Underworld"}
|
||||||
|
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG, third])
|
||||||
|
|
||||||
|
assert_int(Playlist.song_object_array.size()).is_equal(3)
|
||||||
|
assert_int(Settings.player_array[0].player_score).is_equal(1)
|
||||||
|
assert_bool(Playlist.song_object_array[2].has_played).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_another_server_starts_over_with_the_same_players() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
var server_before: String = Settings.default_path
|
||||||
|
Settings.default_path = "https://other.example.com"
|
||||||
|
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
Settings.default_path = server_before
|
||||||
|
assert_int(Settings.player_array.size()).is_equal(2)
|
||||||
|
assert_array(_scores()).contains_exactly([0, 0])
|
||||||
|
|
||||||
|
|
||||||
|
func test_file_that_cannot_be_read_starts_a_new_quiz() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
FileAccess.open(QuizSave.path(), FileAccess.WRITE).store_string("{ half a fi")
|
||||||
|
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
assert_array(Settings.player_array).is_empty()
|
||||||
|
# Like any start without a saved quiz: the server's songs count as played, and a new
|
||||||
|
# one is downloaded ahead
|
||||||
|
assert_bool(Playlist.song_object_array[0].has_played).is_true()
|
||||||
|
assert_bool(Playlist.song_object_array[1].has_played).is_true()
|
||||||
|
assert_int(Playlist.song_object_array.size()).is_equal(3)
|
||||||
|
# The broken file is replaced by the new quiz
|
||||||
|
assert_object(await _saved()).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_statistics_stay_off_after_a_won_game() -> void:
|
||||||
|
Settings.winning_score = 1
|
||||||
|
await _play_a_little()
|
||||||
|
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
|
||||||
|
assert_bool(Settings.add_to_stats).is_false()
|
||||||
|
_press("QuizToolbar/ResetPointsButton")
|
||||||
|
assert_bool(Settings.add_to_stats).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_nothing_is_saved_before_the_saved_songs_are_back() -> void:
|
||||||
|
await _play_a_little()
|
||||||
|
_server.hold("/music/list")
|
||||||
|
await _restart_game([SONG_INFO[0], SECOND_SONG])
|
||||||
|
var file_before: String = FileAccess.get_file_as_string(QuizSave.path())
|
||||||
|
|
||||||
|
# A player added while the server has not answered yet
|
||||||
|
_add_player("Lucca")
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
|
||||||
|
assert_str(FileAccess.get_file_as_string(QuizSave.path())).is_equal(file_before)
|
||||||
|
_server.release("/music/list")
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
assert_int((await _saved()).players.size()).is_equal(3)
|
||||||
|
assert_int((await _saved()).songs.size()).is_equal(2)
|
||||||
|
|
||||||
|
|
||||||
|
# Characters
|
||||||
|
|
||||||
|
func test_character_picture_comes_back_after_a_restart() -> void:
|
||||||
|
var picture: Image = Image.create(20, 10, false, Image.FORMAT_RGB8)
|
||||||
|
_server.respond_bytes("/character?name=Frog.png", picture.save_png_to_buffer())
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
_player_row(crono).new_character_selected(ImageTexture.create_from_image(picture),
|
||||||
|
"Frog.png")
|
||||||
|
_quiz.save_soon()
|
||||||
|
|
||||||
|
await _restart_game()
|
||||||
|
|
||||||
|
crono = Settings.player_array[0]
|
||||||
|
assert_str(crono.character_name).is_equal("Frog.png")
|
||||||
|
assert_vector(crono.character.get_size()).is_equal(Vector2(20, 10))
|
||||||
|
assert_object(_player_row(crono).character.texture_normal).is_same(crono.character)
|
||||||
|
|
||||||
|
|
||||||
|
func test_characters_from_a_folder_picked_for_the_session_are_reset_after_a_restart() -> void:
|
||||||
|
var folder: String = create_temp_dir("session_characters_%d" % randi())
|
||||||
|
FileAccess.open(folder.path_join("Santa.png"), FileAccess.WRITE).store_buffer(
|
||||||
|
Image.create(8, 4, false, Image.FORMAT_RGB8).save_png_to_buffer())
|
||||||
|
_window.settings_popup.use_character_folder(folder)
|
||||||
|
var crono: PlayerObject = _add_player("Crono")
|
||||||
|
_player_row(crono).new_character_selected(ImageTexture.create_from_image(
|
||||||
|
Image.create(8, 4, false, Image.FORMAT_RGB8)), "Santa.png")
|
||||||
|
_quiz.save_soon()
|
||||||
|
|
||||||
|
# The folder is only used until the game is closed
|
||||||
|
await _restart_game()
|
||||||
|
|
||||||
|
assert_str(Settings.player_array[0].character_name).is_empty()
|
||||||
|
assert_str(Settings.player_array[0].character.resource_path) \
|
||||||
|
.is_equal(PlayerObject.NO_CHARACTER_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
# A music folder
|
||||||
|
|
||||||
|
func _prepared_quiz_folder() -> String:
|
||||||
|
var root: String = create_temp_dir("continue_offline_%d" % randi())
|
||||||
|
for file_name: String in ["01. Opening.mp3", "02. Jingle Bells.mp3", "03. Finale.mp3"]:
|
||||||
|
FileAccess.open(root.path_join(file_name), FileAccess.WRITE).store_buffer(_mp3)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
func test_quiz_from_a_music_folder_goes_on_offline() -> void:
|
||||||
|
var folder: String = _prepared_quiz_folder()
|
||||||
|
_window.settings_popup.use_music_folder(folder)
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
_add_player("Crono")
|
||||||
|
_press("SongControls/NextButton")
|
||||||
|
await await_millis(700)
|
||||||
|
await _give_point_with_keybind(1)
|
||||||
|
|
||||||
|
await _restart_game()
|
||||||
|
|
||||||
|
assert_bool(Settings.is_offline()).is_true()
|
||||||
|
assert_str(Settings.music_folder.path).is_equal(folder)
|
||||||
|
assert_bool(_window.top_bar.offline_label.visible).is_true()
|
||||||
|
assert_int(Settings.player_array[0].player_score).is_equal(1)
|
||||||
|
assert_str(Playlist.song_object_array[0].song_title).is_equal("01. Opening")
|
||||||
|
assert_array(_server.requested_paths.filter(func(path: String) -> bool:
|
||||||
|
return path.begins_with("/music"))).is_empty()
|
||||||
|
|
||||||
|
_press("SongControls/NextButton")
|
||||||
|
await await_millis(700)
|
||||||
|
assert_str(Playlist.song_object_array[Playlist.currently_playing_song].song_title) \
|
||||||
|
.is_equal("02. Jingle Bells")
|
||||||
|
|
||||||
|
|
||||||
|
func test_music_folder_that_is_gone_starts_over_with_the_server() -> void:
|
||||||
|
var folder: String = _prepared_quiz_folder()
|
||||||
|
_window.settings_popup.use_music_folder(folder)
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
_add_player("Crono")
|
||||||
|
_press("SongControls/NextButton")
|
||||||
|
await await_millis(700)
|
||||||
|
await _give_point_with_keybind(1)
|
||||||
|
for file_name: String in DirAccess.get_files_at(folder):
|
||||||
|
DirAccess.remove_absolute(folder.path_join(file_name))
|
||||||
|
|
||||||
|
await _restart_game()
|
||||||
|
|
||||||
|
assert_bool(Settings.is_offline()).is_false()
|
||||||
|
assert_int(Settings.player_array.size()).is_equal(1)
|
||||||
|
assert_array(_scores()).contains_exactly([0])
|
||||||
|
assert_str(Playlist.song_object_array[0].soundtrack_title).is_equal("Chrono Trigger")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dgxvnsquk18ud
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
extends MainWindowTestSuite
|
||||||
|
## Soundtrack covers on the statistics cards, in the popup and next to the answer in the
|
||||||
|
## quiz, loaded for the cards on screen; changing a cover from the popup; and editing a
|
||||||
|
## soundtrack's alternative names, which search then finds it by.
|
||||||
|
|
||||||
|
const STATISTICS: Array[Dictionary] = [
|
||||||
|
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
|
||||||
|
"cover_modified": 100, "alternative_names": [], "songs": [
|
||||||
|
{"song_name": "Corridors of Time", "times_played": 1},
|
||||||
|
]},
|
||||||
|
{"soundtrack_uuid": "st-2", "soundtrack_name": "Terranigma", "soundtrack_played": 0,
|
||||||
|
"songs": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
var _view: StatisticsView
|
||||||
|
var _popup: SoundtrackStatsPopup
|
||||||
|
var _png: PackedByteArray
|
||||||
|
|
||||||
|
|
||||||
|
func _before_window_starts() -> void:
|
||||||
|
_server.respond_json("/statistics/soundtracks", STATISTICS)
|
||||||
|
var image: Image = Image.create(6, 8, false, Image.FORMAT_RGB8)
|
||||||
|
_png = image.save_png_to_buffer()
|
||||||
|
_server.respond_bytes("/soundtracks/st-1/cover", _png)
|
||||||
|
# The soundtrack list tells the quiz which soundtracks have covers
|
||||||
|
_server.respond_json("/soundtracks", [
|
||||||
|
{"uuid": "st-1", "name": "Chrono Trigger", "cover_modified": 100},
|
||||||
|
{"uuid": "st-2", "name": "Terranigma"},
|
||||||
|
])
|
||||||
|
_next_song_is({"SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "st-1",
|
||||||
|
"Song": "Corridors of Time"})
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
# Covers are kept next to the settings, which the tests keep in a folder of their own
|
||||||
|
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("covers_%d" % randi()))
|
||||||
|
await super()
|
||||||
|
_view = _window.get_node("StatisticsView")
|
||||||
|
_popup = _window.get_node("SoundtrackStatsPopup")
|
||||||
|
|
||||||
|
|
||||||
|
func _open_statistics() -> void:
|
||||||
|
_window.show_tab(&"statistics")
|
||||||
|
await _runner.simulate_frames(3)
|
||||||
|
|
||||||
|
|
||||||
|
func _card(soundtrack_name: String) -> SoundtrackCard:
|
||||||
|
for card: SoundtrackCard in _view.cards:
|
||||||
|
if card.soundtrack.soundtrack_name == soundtrack_name:
|
||||||
|
return card
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func test_card_shows_its_cover() -> void:
|
||||||
|
await _open_statistics()
|
||||||
|
|
||||||
|
var card: SoundtrackCard = _card("Chrono Trigger")
|
||||||
|
assert_object(card.cover_picture.texture).is_not_null()
|
||||||
|
assert_bool(card.cover_placeholder.visible).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_card_without_a_cover_shows_initials_and_asks_nothing() -> void:
|
||||||
|
await _open_statistics()
|
||||||
|
|
||||||
|
var card: SoundtrackCard = _card("Terranigma")
|
||||||
|
assert_object(card.cover_picture.texture).is_null()
|
||||||
|
assert_bool(card.cover_placeholder.visible).is_true()
|
||||||
|
assert_str(card.cover_placeholder.text).is_equal("T")
|
||||||
|
assert_int(_server.count("/soundtracks/st-2/cover")).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_cover_missing_on_the_server_keeps_the_initials() -> void:
|
||||||
|
_server.respond_bytes("/soundtracks/st-1/cover", PackedByteArray(), 404)
|
||||||
|
|
||||||
|
await _open_statistics()
|
||||||
|
|
||||||
|
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
|
||||||
|
assert_str(_card("Chrono Trigger").cover_placeholder.text).is_equal("CT")
|
||||||
|
|
||||||
|
|
||||||
|
func test_covers_load_for_cards_on_screen_as_they_scroll_in() -> void:
|
||||||
|
var many: Array[Dictionary] = []
|
||||||
|
for i: int in 40:
|
||||||
|
many.append({"soundtrack_uuid": "many-%02d" % i, "soundtrack_name": "Game %02d" % i,
|
||||||
|
"soundtrack_played": 1, "cover_modified": 100, "songs": []})
|
||||||
|
_server.respond_bytes("/soundtracks/many-%02d/cover" % i, _png)
|
||||||
|
_server.respond_json("/statistics/soundtracks", many)
|
||||||
|
|
||||||
|
await _open_statistics()
|
||||||
|
|
||||||
|
var asked: Callable = func() -> int:
|
||||||
|
return _server.requested_paths.filter(
|
||||||
|
func(path: String) -> bool: return path.begins_with("/soundtracks/many-")).size()
|
||||||
|
var on_screen: int = asked.call()
|
||||||
|
assert_int(on_screen).is_greater(0)
|
||||||
|
assert_int(on_screen).is_less(40)
|
||||||
|
|
||||||
|
var scroll_bar: VScrollBar = _view.card_scroll.get_v_scroll_bar()
|
||||||
|
scroll_bar.value = scroll_bar.max_value
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
|
||||||
|
assert_int(asked.call()).is_greater(on_screen)
|
||||||
|
|
||||||
|
|
||||||
|
func test_popup_shows_the_cover_large() -> void:
|
||||||
|
await _open_statistics()
|
||||||
|
|
||||||
|
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||||
|
|
||||||
|
assert_object(_popup.cover_picture.texture).is_not_null()
|
||||||
|
assert_bool(_popup.cover_placeholder.visible).is_false()
|
||||||
|
assert_str(_popup.names_label.text).is_equal("No other names yet")
|
||||||
|
|
||||||
|
|
||||||
|
func test_editing_names_saves_them_and_search_finds_the_new_name() -> void:
|
||||||
|
_server.respond_json("/soundtracks/st-1/alternative-names", {"uuid": "st-1",
|
||||||
|
"name": "Chrono Trigger", "alternative_names": ["CT", "クロノ・トリガー"]})
|
||||||
|
await _open_statistics()
|
||||||
|
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||||
|
|
||||||
|
_popup.edit_names()
|
||||||
|
assert_bool(_popup.names_editor.visible).is_true()
|
||||||
|
_popup.new_name_field.text = "CT"
|
||||||
|
_popup.add_name()
|
||||||
|
_popup.new_name_field.text = "クロノ・トリガー"
|
||||||
|
_popup.save_names()
|
||||||
|
|
||||||
|
var index: int = _server.requested_paths.find("/soundtracks/st-1/alternative-names")
|
||||||
|
assert_int(index).is_not_equal(-1)
|
||||||
|
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
|
||||||
|
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
|
||||||
|
assert_array(body.names).contains_exactly(["CT", "クロノ・トリガー"])
|
||||||
|
assert_bool(_popup.names_editor.visible).is_false()
|
||||||
|
assert_str(_popup.names_label.text).is_equal("CT, クロノ・トリガー")
|
||||||
|
|
||||||
|
_view.search_field.text = "クロノ"
|
||||||
|
_view.apply_filter()
|
||||||
|
var shown: Array = _view.cards.filter(func(card: SoundtrackCard) -> bool: return card.visible)
|
||||||
|
assert_array(shown.map(func(card: SoundtrackCard) -> String:
|
||||||
|
return card.soundtrack.soundtrack_name)).contains_exactly(["Chrono Trigger"])
|
||||||
|
|
||||||
|
|
||||||
|
func test_editor_skips_empty_repeated_and_own_names() -> void:
|
||||||
|
await _open_statistics()
|
||||||
|
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||||
|
_popup.edit_names()
|
||||||
|
|
||||||
|
for name: String in [" ", "CT", "ct", "Chrono Trigger"]:
|
||||||
|
_popup.new_name_field.text = name
|
||||||
|
_popup.add_name()
|
||||||
|
|
||||||
|
assert_int(_popup.editor_names.get_child_count()).is_equal(1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_removing_a_name_in_the_editor() -> void:
|
||||||
|
await _open_statistics()
|
||||||
|
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||||
|
_popup.edit_names()
|
||||||
|
_popup.new_name_field.text = "CT"
|
||||||
|
_popup.add_name()
|
||||||
|
|
||||||
|
_popup.remove_name("CT")
|
||||||
|
|
||||||
|
assert_int(_popup.editor_names.get_child_count()).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_failed_save_keeps_the_editor_open() -> void:
|
||||||
|
_server.respond_json("/soundtracks/st-1/alternative-names", {}, 500)
|
||||||
|
await _open_statistics()
|
||||||
|
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||||
|
_popup.edit_names()
|
||||||
|
_popup.new_name_field.text = "CT"
|
||||||
|
|
||||||
|
_popup.save_names()
|
||||||
|
|
||||||
|
assert_bool(_popup.names_editor.visible).is_true()
|
||||||
|
assert_bool(_popup.editor_status.visible).is_true()
|
||||||
|
assert_bool(_popup.save_names_button.disabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_search_popup_finds_soundtracks_by_alternative_names() -> void:
|
||||||
|
_server.respond_json("/soundtracks", [
|
||||||
|
{"uuid": "st-1", "name": "Chrono Trigger", "alternative_names": ["CT"]},
|
||||||
|
{"uuid": "st-2", "name": "Terranigma", "alternative_names": []},
|
||||||
|
])
|
||||||
|
var search: SearchPopup = _window.get_node("SearchPopup")
|
||||||
|
search.get_list_of_soundtracks()
|
||||||
|
search.open()
|
||||||
|
|
||||||
|
search.search_field.text = "ct"
|
||||||
|
search.search()
|
||||||
|
|
||||||
|
assert_array(search.shown_names()).contains(["Chrono Trigger"])
|
||||||
|
var row: PanelContainer = search.search_results.get_child(0)
|
||||||
|
assert_str((row.get_child(0) as RichTextLabel).get_parsed_text()).contains("CT")
|
||||||
|
|
||||||
|
|
||||||
|
func _open_cover_menu() -> void:
|
||||||
|
await _open_statistics()
|
||||||
|
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||||
|
_popup.change_cover()
|
||||||
|
|
||||||
|
|
||||||
|
# The server's answer for the soundtrack once its cover changed
|
||||||
|
func _cover_time_becomes(modified: int) -> void:
|
||||||
|
_server.respond_json("/soundtracks/st-1", {"uuid": "st-1", "name": "Chrono Trigger",
|
||||||
|
"cover_modified": modified, "songs": []})
|
||||||
|
|
||||||
|
|
||||||
|
func _picture_file(bytes: PackedByteArray) -> String:
|
||||||
|
var path: String = create_temp_dir("pictures_%d" % randi()).path_join("cover.jpg")
|
||||||
|
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
file.store_buffer(bytes)
|
||||||
|
file.close()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
func _downloads(path: String) -> int:
|
||||||
|
var count: int = 0
|
||||||
|
for i: int in _server.requested_paths.size():
|
||||||
|
if _server.requested_paths[i] == path \
|
||||||
|
&& _server.requested_methods[i] == HTTPClient.METHOD_GET:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
func _now_playing() -> NowPlaying:
|
||||||
|
return _quiz.get_node("NowPlaying")
|
||||||
|
|
||||||
|
|
||||||
|
func test_cover_menu_opens_in_place_of_the_songs() -> void:
|
||||||
|
await _open_cover_menu()
|
||||||
|
|
||||||
|
assert_bool(_popup.cover_editor.visible).is_true()
|
||||||
|
assert_bool(_popup.song_scroll.visible).is_false()
|
||||||
|
assert_bool(_popup.edit_names_button.disabled).is_true()
|
||||||
|
assert_bool(_popup.remove_cover_button.disabled).is_false()
|
||||||
|
|
||||||
|
_popup.stop_editing()
|
||||||
|
|
||||||
|
assert_bool(_popup.cover_editor.visible).is_false()
|
||||||
|
assert_bool(_popup.song_scroll.visible).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_soundtrack_without_a_cover_has_nothing_to_remove() -> void:
|
||||||
|
await _open_statistics()
|
||||||
|
_popup.show_soundtrack(_card("Terranigma").soundtrack)
|
||||||
|
|
||||||
|
_popup.change_cover()
|
||||||
|
|
||||||
|
assert_bool(_popup.remove_cover_button.disabled).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_uploaded_picture_becomes_the_cover() -> void:
|
||||||
|
var bytes: PackedByteArray = FileAccess.get_file_as_bytes(
|
||||||
|
"res://tests/data/covers/cover_0.jpg")
|
||||||
|
_cover_time_becomes(200)
|
||||||
|
await _open_cover_menu()
|
||||||
|
|
||||||
|
_popup.upload_picture(_picture_file(bytes))
|
||||||
|
|
||||||
|
var index: int = _server.requested_methods.find(HTTPClient.METHOD_PUT)
|
||||||
|
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
|
||||||
|
assert_array(_server.requested_raw_bodies[index]).is_equal(bytes)
|
||||||
|
assert_array(_server.requested_headers[index]).contains(["Content-Type: image/jpeg"])
|
||||||
|
# The new cover is downloaded again for the popup and the card
|
||||||
|
assert_int(_card("Chrono Trigger").soundtrack.cover_modified).is_equal(200)
|
||||||
|
await _runner.simulate_frames(2)
|
||||||
|
# Downloaded once more, for the popup and the card together
|
||||||
|
assert_int(_downloads("/soundtracks/st-1/cover")).is_equal(2)
|
||||||
|
assert_object(_card("Chrono Trigger").cover_picture.texture).is_not_null()
|
||||||
|
assert_str(_popup.cover_status.text).is_equal("The new cover is saved.")
|
||||||
|
|
||||||
|
|
||||||
|
func test_file_that_is_not_a_picture_is_not_uploaded() -> void:
|
||||||
|
await _open_cover_menu()
|
||||||
|
|
||||||
|
_popup.upload_picture(_picture_file("not a picture".to_utf8_buffer()))
|
||||||
|
|
||||||
|
assert_int(_server.requested_methods.count(HTTPClient.METHOD_PUT)).is_equal(0)
|
||||||
|
assert_str(_popup.cover_status.text).contains("JPEG, PNG and WebP")
|
||||||
|
|
||||||
|
|
||||||
|
func test_cover_picked_by_id() -> void:
|
||||||
|
_server.respond_bytes("/soundtracks/st-1/cover/source", PackedByteArray(), 204)
|
||||||
|
_cover_time_becomes(300)
|
||||||
|
await _open_cover_menu()
|
||||||
|
|
||||||
|
_popup.cover_source_button.select(1)
|
||||||
|
_popup.cover_id_field.text = " 21 "
|
||||||
|
_popup.use_cover_source()
|
||||||
|
|
||||||
|
var index: int = _server.requested_paths.find("/soundtracks/st-1/cover/source")
|
||||||
|
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
|
||||||
|
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
|
||||||
|
assert_str(body.source).is_equal("anilist")
|
||||||
|
assert_str(body.id).is_equal("21")
|
||||||
|
assert_int(_popup.soundtrack.cover_modified).is_equal(300)
|
||||||
|
|
||||||
|
|
||||||
|
func test_no_id_asks_nothing() -> void:
|
||||||
|
await _open_cover_menu()
|
||||||
|
|
||||||
|
_popup.use_cover_source()
|
||||||
|
|
||||||
|
assert_int(_server.count("/soundtracks/st-1/cover/source")).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_removed_cover_shows_the_initials() -> void:
|
||||||
|
_cover_time_becomes(0)
|
||||||
|
await _open_cover_menu()
|
||||||
|
|
||||||
|
_popup.remove_cover()
|
||||||
|
|
||||||
|
var index: int = _server.requested_methods.find(HTTPClient.METHOD_DELETE)
|
||||||
|
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
|
||||||
|
assert_bool(_popup.cover_placeholder.visible).is_true()
|
||||||
|
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
|
||||||
|
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
|
||||||
|
assert_bool(_popup.remove_cover_button.disabled).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_refused_change_shows_the_servers_reason() -> void:
|
||||||
|
_server.respond_json("/soundtracks/st-1/cover/source",
|
||||||
|
{"error": "covers are not enabled on this server"}, 503)
|
||||||
|
await _open_cover_menu()
|
||||||
|
_popup.cover_id_field.text = "21"
|
||||||
|
|
||||||
|
_popup.use_cover_source()
|
||||||
|
|
||||||
|
assert_str(_popup.cover_status.text).is_equal(
|
||||||
|
"Could not change the cover: covers are not enabled on this server.")
|
||||||
|
assert_bool(_popup.use_source_button.disabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_answer_shows_the_soundtracks_cover() -> void:
|
||||||
|
await _play_first_song()
|
||||||
|
|
||||||
|
assert_bool(_now_playing().has_cover()).is_false()
|
||||||
|
|
||||||
|
_press("SongControls/ShowAnswerButton")
|
||||||
|
|
||||||
|
assert_bool(_now_playing().has_cover()).is_true()
|
||||||
|
await _runner.simulate_frames(1)
|
||||||
|
# The cover sits just left of the titles, and the two are centered together
|
||||||
|
var area: Rect2 = _now_playing().get_global_rect()
|
||||||
|
var cover: Rect2 = _now_playing().cover_frame.get_global_rect()
|
||||||
|
var titles: Rect2 = _now_playing().titles.get_global_rect()
|
||||||
|
assert_float(titles.position.x - cover.end.x).is_equal(32.0)
|
||||||
|
assert_float(absf((cover.position.x - area.position.x) - (area.end.x - titles.end.x))) \
|
||||||
|
.is_less_equal(1.0)
|
||||||
|
# Up and down, the cover is centered between the top bar and the song list, and the
|
||||||
|
# titles on the cover
|
||||||
|
assert_float(absf((cover.position.y - area.position.y) - (area.end.y - cover.end.y))) \
|
||||||
|
.is_less_equal(1.0)
|
||||||
|
var text_middle: float = (_now_playing().soundtrack_label.get_global_rect().position.y
|
||||||
|
+ _now_playing().song_label.get_global_rect().end.y) / 2.0
|
||||||
|
assert_float(absf(text_middle - cover.get_center().y)).is_less_equal(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_answer_without_a_cover_shows_only_the_titles() -> void:
|
||||||
|
_view.covers.set_cover_times({"st-1": 0})
|
||||||
|
await _play_first_song()
|
||||||
|
|
||||||
|
_press("SongControls/ShowAnswerButton")
|
||||||
|
|
||||||
|
assert_bool(_now_playing().has_cover()).is_false()
|
||||||
|
assert_int(_server.count("/soundtracks/st-1/cover")).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_next_song_hides_the_cover_until_its_answer() -> void:
|
||||||
|
await _play_first_song()
|
||||||
|
_press("SongControls/ShowAnswerButton")
|
||||||
|
|
||||||
|
_press("SongControls/NextButton")
|
||||||
|
# The playing song is stopped first, which takes half a second
|
||||||
|
await await_millis(700)
|
||||||
|
|
||||||
|
assert_int(Playlist.currently_playing_song).is_equal(1)
|
||||||
|
assert_bool(_now_playing().has_cover()).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_changed_cover_shows_in_the_quiz_too() -> void:
|
||||||
|
await _play_first_song()
|
||||||
|
_press("SongControls/ShowAnswerButton")
|
||||||
|
_cover_time_becomes(0)
|
||||||
|
_popup.show_soundtrack(SoundtrackWithSongs.from_dict(STATISTICS[0]))
|
||||||
|
_popup.change_cover()
|
||||||
|
|
||||||
|
_popup.remove_cover()
|
||||||
|
|
||||||
|
assert_bool(_now_playing().has_cover()).is_false()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d3oc3e1fsttoh
|
||||||
@@ -6,7 +6,7 @@ var _qr: QRPopup
|
|||||||
|
|
||||||
|
|
||||||
func before_test() -> void:
|
func before_test() -> void:
|
||||||
super()
|
await super()
|
||||||
_log = _window.get_node("LogPopup")
|
_log = _window.get_node("LogPopup")
|
||||||
_qr = _window.get_node("QRPopup")
|
_qr = _window.get_node("QRPopup")
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user