ci / verify (push) Successful in 48s
Buttons were the easy half. This is everything else. Dialogs sit on panels now rather than being text over a dimmed world -- the pause menu, character select, upgrades, settings and credits all root through UiTheme.dialog_panel(). The panel stylebox is the pack's frame MODULATED DARK. The set is cream throughout, which is right for buttons and wrong for a dialog over a dark dungeon; tinting keeps the pixel border and the corner shape and lets every label the game already draws in light colours stay readable, instead of recolouring every label in five screens to suit the art. Sliders and scrollbars are the pack's too, section headings sit on its banner ribbon, and an upgrade card's rarity is now its frame rather than a word on it -- three choices are compared at a glance and a colour reads faster than a label. Two bugs of the same shape, and neither was findable without looking at the screen. A Slider and a ScrollBar take their THICKNESS from the stylebox's minimum size, which for a StyleBoxTexture is its content margins. Mine were zero, so both resolved the correct stylebox, reported the correct texture, and drew a groove zero pixels tall. A probe confirmed the theme was resolving perfectly while the track was invisible. Tests now assert every slider and scrollbar stylebox has a non-zero minimum, and it is gotcha 7 in CLAUDE.md. The first attempt at the slider groove also used the pack's HOLLOW bar sprite, whose middle is transparent -- tinting it dark left an outline and nothing else. It uses the solid one. tools/screenshot.tscn gained the upgrade screen, which it has to stage: the game scene owns that screen's visibility and re-asserts it every frame, so setting `visible` lasted exactly one frame, and standing the player at the NPC client-side lasted until reconciliation pulled them back. It now moves the player on both sides and holds it until the shot. That tool has caught four bugs the automated gates all passed. check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
7.8 KiB
GDScript
247 lines
7.8 KiB
GDScript
extends CanvasLayer
|
|
## Volumes and key bindings.
|
|
##
|
|
## Reachable from the main menu and from the in-game menu, and the same screen
|
|
## both times: settings are local to this client, so there is nothing about the
|
|
## game's state that should change what it can do.
|
|
|
|
signal closed
|
|
signal credits_requested
|
|
|
|
var _rows: Dictionary[String, Button] = {}
|
|
var _status: Label
|
|
var _master: HSlider
|
|
var _sfx: HSlider
|
|
var _master_value: Label
|
|
var _sfx_value: Label
|
|
## The action currently waiting for a key press, or empty.
|
|
var _listening: String = ""
|
|
|
|
|
|
func _ready() -> void:
|
|
layer = 32
|
|
visible = false
|
|
var root := UiTheme.themed_root()
|
|
add_child(root)
|
|
|
|
var scrim := ColorRect.new()
|
|
scrim.color = Color(0.03, 0.03, 0.06, 0.94)
|
|
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
scrim.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
root.add_child(scrim)
|
|
|
|
var centre := CenterContainer.new()
|
|
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
root.add_child(centre)
|
|
|
|
# A panel, so a dialog reads as a thing laid over the world rather
|
|
# than as text floating on a dimmed one.
|
|
var frame := UiTheme.dialog_panel()
|
|
centre.add_child(frame)
|
|
|
|
var panel := VBoxContainer.new()
|
|
panel.custom_minimum_size = Vector2(520.0, 0.0)
|
|
panel.add_theme_constant_override("separation", 8)
|
|
frame.add_child(panel)
|
|
|
|
var title := Label.new()
|
|
title.text = "SETTINGS"
|
|
title.add_theme_font_size_override("font_size", 26)
|
|
panel.add_child(title)
|
|
|
|
panel.add_child(_heading("SOUND"))
|
|
var master_row := _slider_row(panel, "Master")
|
|
_master = master_row[0]
|
|
_master_value = master_row[1]
|
|
var sfx_row := _slider_row(panel, "Effects")
|
|
_sfx = sfx_row[0]
|
|
_sfx_value = sfx_row[1]
|
|
|
|
panel.add_child(_heading("CONTROLS"))
|
|
_status = Label.new()
|
|
_status.add_theme_font_size_override("font_size", 12)
|
|
_status.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5))
|
|
panel.add_child(_status)
|
|
|
|
# Scrolled: thirteen rows is more than fits on a short window, and a list
|
|
# that runs off the bottom of the screen is a list with unreachable entries.
|
|
var scroll := ScrollContainer.new()
|
|
scroll.custom_minimum_size = Vector2(520.0, 300.0)
|
|
panel.add_child(scroll)
|
|
var list := VBoxContainer.new()
|
|
list.custom_minimum_size = Vector2(500.0, 0.0)
|
|
list.add_theme_constant_override("separation", 3)
|
|
scroll.add_child(list)
|
|
for entry in Settings.REBINDABLE:
|
|
_rows[String(entry[0])] = _binding_row(list, String(entry[0]), String(entry[1]))
|
|
|
|
var buttons := HBoxContainer.new()
|
|
buttons.add_theme_constant_override("separation", 8)
|
|
panel.add_child(buttons)
|
|
var reset := Button.new()
|
|
reset.text = "Reset controls"
|
|
reset.custom_minimum_size = Vector2(160.0, 34.0)
|
|
reset.pressed.connect(func() -> void:
|
|
Settings.reset_bindings()
|
|
_status.text = "controls reset"
|
|
refresh())
|
|
buttons.add_child(reset)
|
|
var credits := Button.new()
|
|
credits.text = "Credits"
|
|
credits.custom_minimum_size = Vector2(160.0, 34.0)
|
|
credits.pressed.connect(func() -> void: credits_requested.emit())
|
|
buttons.add_child(credits)
|
|
var close := Button.new()
|
|
close.text = "Close"
|
|
close.custom_minimum_size = Vector2(160.0, 34.0)
|
|
close.pressed.connect(func() -> void: closed.emit())
|
|
buttons.add_child(close)
|
|
|
|
|
|
## A section heading on the pack's banner ribbon, so the two halves of this
|
|
## screen are visibly two sections rather than one long list.
|
|
## A section heading on the pack's banner ribbon.
|
|
##
|
|
## Deliberately narrow and centred rather than stretched across the dialog: at
|
|
## full width the ribbon stops reading as a label and becomes a bright divider
|
|
## competing with everything under it.
|
|
func _heading(text: String) -> Control:
|
|
var row := CenterContainer.new()
|
|
var holder := MarginContainer.new()
|
|
holder.custom_minimum_size = Vector2(210.0, 34.0)
|
|
row.add_child(holder)
|
|
var banner := NinePatchRect.new()
|
|
banner.texture = UiTheme.texture("banner")
|
|
# The ribbon's tails hang off both ends; stretching through them would
|
|
# smear the shape, so they are the patch margins and only the body grows.
|
|
banner.patch_margin_left = 14
|
|
banner.patch_margin_right = 14
|
|
banner.patch_margin_top = 8
|
|
banner.patch_margin_bottom = 8
|
|
holder.add_child(banner)
|
|
var l := Label.new()
|
|
l.text = text
|
|
l.add_theme_font_size_override("font_size", 13)
|
|
l.add_theme_color_override("font_color", UiTheme.INK)
|
|
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
holder.add_child(l)
|
|
return row
|
|
|
|
|
|
func _slider_row(parent: Control, label: String) -> Array:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 10)
|
|
parent.add_child(row)
|
|
var name_label := Label.new()
|
|
name_label.text = label
|
|
name_label.custom_minimum_size = Vector2(90.0, 0.0)
|
|
row.add_child(name_label)
|
|
var slider := HSlider.new()
|
|
slider.min_value = 0.0
|
|
slider.max_value = 1.0
|
|
slider.step = 0.05
|
|
slider.custom_minimum_size = Vector2(340.0, 20.0)
|
|
row.add_child(slider)
|
|
var value := Label.new()
|
|
value.custom_minimum_size = Vector2(50.0, 0.0)
|
|
row.add_child(value)
|
|
return [slider, value]
|
|
|
|
|
|
func _binding_row(parent: Control, action: String, label: String) -> Button:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 10)
|
|
parent.add_child(row)
|
|
var name_label := Label.new()
|
|
name_label.text = label
|
|
name_label.custom_minimum_size = Vector2(260.0, 0.0)
|
|
row.add_child(name_label)
|
|
var button := Button.new()
|
|
button.custom_minimum_size = Vector2(200.0, 30.0)
|
|
button.pressed.connect(func() -> void: _listen_for(action))
|
|
row.add_child(button)
|
|
return button
|
|
|
|
|
|
func open() -> void:
|
|
visible = true
|
|
_listening = ""
|
|
_status.text = ""
|
|
refresh()
|
|
|
|
|
|
func refresh() -> void:
|
|
_master.value = Settings.master_volume
|
|
_sfx.value = Settings.sfx_volume
|
|
_master_value.text = "%d%%" % roundi(Settings.master_volume * 100.0)
|
|
_sfx_value.text = "%d%%" % roundi(Settings.sfx_volume * 100.0)
|
|
# Connected here rather than in _ready so setting the values above cannot
|
|
# fire the handler and write the file back on every open.
|
|
if not _master.value_changed.is_connected(_on_master):
|
|
_master.value_changed.connect(_on_master)
|
|
_sfx.value_changed.connect(_on_sfx)
|
|
for action in _rows:
|
|
_rows[action].text = "listening..." if action == _listening \
|
|
else Settings.binding_label(action)
|
|
|
|
|
|
func _on_master(value: float) -> void:
|
|
Settings.master_volume = value
|
|
Settings.apply_audio()
|
|
Settings.save()
|
|
_master_value.text = "%d%%" % roundi(value * 100.0)
|
|
|
|
|
|
func _on_sfx(value: float) -> void:
|
|
Settings.sfx_volume = value
|
|
Settings.apply_audio()
|
|
Settings.save()
|
|
_sfx_value.text = "%d%%" % roundi(value * 100.0)
|
|
|
|
|
|
func _listen_for(action: String) -> void:
|
|
_listening = action
|
|
_status.text = "press a key or mouse button (Escape cancels)"
|
|
refresh()
|
|
|
|
|
|
## Captures the next key or mouse button while a row is listening.
|
|
##
|
|
## _input rather than _unhandled_input on purpose: the buttons in this screen
|
|
## consume clicks, and a rebind that could never see a mouse button would be a
|
|
## rebind that cannot bind Fire.
|
|
func _input(event: InputEvent) -> void:
|
|
if not visible or _listening.is_empty():
|
|
return
|
|
if event is InputEventKey and not (event as InputEventKey).pressed:
|
|
return
|
|
if event is InputEventMouseButton and not (event as InputEventMouseButton).pressed:
|
|
return
|
|
if not Settings.is_bindable(event):
|
|
return
|
|
get_viewport().set_input_as_handled()
|
|
var action := _listening
|
|
_listening = ""
|
|
# Escape is the way out of a listening row, so it cannot also be captured
|
|
# by one -- binding the cancel key would leave no way to cancel.
|
|
if event is InputEventKey \
|
|
and (event as InputEventKey).physical_keycode == KEY_ESCAPE:
|
|
_status.text = "cancelled"
|
|
refresh()
|
|
return
|
|
var clash := Settings.rebind(action, event)
|
|
if clash.is_empty():
|
|
_status.text = ""
|
|
else:
|
|
_status.text = "%s is already %s" % [
|
|
Settings.binding_label(clash), _label_of(clash)]
|
|
refresh()
|
|
|
|
|
|
func _label_of(action: String) -> String:
|
|
for entry in Settings.REBINDABLE:
|
|
if String(entry[0]) == action:
|
|
return String(entry[1])
|
|
return action
|