Files
security-game/src/game/ui/start_menu.gd
T
Gig Line Dev 8f65751975
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Skipped
Add wrapping keyboard and gamepad focus rings
- Link every modal's enabled controls into a wrapping UiFocus ring and give each screen a default focus target.
- Add gamepad A/B bindings for ui_accept/ui_cancel; include Start menu footer actions and Continue in its focus ring.
- Skip hidden/disabled controls, close focus traps via per-modal cancel behavior, and return review to the menu on cancel.
- Record the focus audit and add regression coverage.
2026-11-02 16:05:53 +01:00

249 lines
8.5 KiB
GDScript

class_name StartMenu
extends CanvasLayer
## Startup menu: pick a venue, start the shift, open settings, or quit.
## Keyboard and mouse both work. Replaces `V` as the player-facing venue path
## (the `V` bind stays as a debug shortcut).
signal venue_selected(scenario_id: String)
signal continue_requested()
signal help_requested()
signal settings_requested()
signal quit_requested()
const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const Strings := preload("res://src/core/strings.gd")
const VENUES := [
{"id": "hollywood_building", "label_key": "venue.hollywood_day.label",
"blurb_key": "venue.hollywood_day.blurb"},
{"id": "hollywood_building_night", "label_key": "venue.hollywood_night.label",
"blurb_key": "venue.hollywood_night.blurb"},
{"id": "joke_shop", "label_key": "venue.joke.label", "blurb_key": "venue.joke.blurb"},
{"id": "tv_studio", "label_key": "venue.studio.label", "blurb_key": "venue.studio.blurb"},
]
var _index := 0
var _buttons: Array[Button] = []
var _footer_buttons: Array[Button] = []
var _focus_targets: Array[Button] = []
var _root: Control = null
var _status: Label = null
var _continue_button: Button = null
var _campaign: Dictionary = {}
func _ready() -> void:
layer = 20
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "StartMenuRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var art := UiArt.new()
var backdrop_texture := art.texture("start_menu")
if backdrop_texture != null:
var backdrop := TextureRect.new()
backdrop.name = "MenuArt"
backdrop.texture = backdrop_texture
backdrop.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
backdrop.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
backdrop.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(backdrop)
# The mockup includes its own title/buttons; cover that panel with an
# opaque rounded panel and keep the real controls on top.
var panel := ColorRect.new()
panel.name = "MenuPanel"
panel.color = Color(0.075, 0.08, 0.095, 1.0)
panel.set_anchor(SIDE_LEFT, 0.148)
panel.set_anchor(SIDE_TOP, 0.061)
panel.set_anchor(SIDE_RIGHT, 0.781)
panel.set_anchor(SIDE_BOTTOM, 0.921)
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(panel)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.18)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(dim)
else:
var bg := ColorRect.new()
bg.color = Color(0.03, 0.04, 0.07, 0.96)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(bg)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.name = "Column"
column.custom_minimum_size = Vector2(460, 0)
column.add_theme_constant_override("separation", 14)
center.add_child(column)
var title := Label.new()
title.text = Strings.lookup("ui.start_title")
title.add_theme_font_size_override("font_size", 44)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
var subtitle := Label.new()
subtitle.text = Strings.lookup("ui.start_subtitle")
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
column.add_child(subtitle)
_status = Label.new()
_status.name = "CampaignStatus"
_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_status.add_theme_color_override("font_color", Color(0.62, 0.78, 0.9))
column.add_child(_status)
_continue_button = Button.new()
_continue_button.name = "ContinueButton"
_continue_button.custom_minimum_size = Vector2(460, 52)
_continue_button.pressed.connect(_on_continue_pressed)
UiStyles.apply_button(_continue_button, 18)
column.add_child(_continue_button)
for i in VENUES.size():
var venue: Dictionary = VENUES[i]
var button := Button.new()
button.name = "Venue_%s" % venue["id"]
button.text = "%s\n%s" % [Strings.lookup(str(venue["label_key"])), Strings.lookup(str(venue["blurb_key"]))]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = Vector2(460, 62)
button.pressed.connect(_on_venue_pressed.bind(i))
UiStyles.apply_button(button, 18)
column.add_child(button)
_buttons.append(button)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 10)
column.add_child(spacer)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
column.add_child(row)
var help_button := Button.new()
help_button.name = "HelpButton"
help_button.text = Strings.lookup("ui.start_help")
help_button.pressed.connect(func(): help_requested.emit())
UiStyles.apply_button(help_button, 16)
row.add_child(help_button)
_footer_buttons.append(help_button)
var settings_button := Button.new()
settings_button.name = "SettingsButton"
settings_button.text = Strings.lookup("ui.start_settings")
settings_button.pressed.connect(func(): settings_requested.emit())
UiStyles.apply_button(settings_button, 16)
row.add_child(settings_button)
_footer_buttons.append(settings_button)
var quit_button := Button.new()
quit_button.name = "QuitButton"
quit_button.text = Strings.lookup("ui.quit_game")
quit_button.pressed.connect(func(): quit_requested.emit())
UiStyles.apply_button(quit_button, 16)
row.add_child(quit_button)
_footer_buttons.append(quit_button)
var hint := Label.new()
hint.text = Strings.lookup("ui.start_hint")
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
_refresh_focus_targets()
_focus_index(0)
## Continue + venue rows + footer actions, in visual order. Hidden Continue is
## skipped so keyboard/gamepad focus never lands on an invisible control.
func _refresh_focus_targets() -> void:
_focus_targets.clear()
if _continue_button and _continue_button.visible:
_focus_targets.append(_continue_button)
_focus_targets.append_array(_buttons)
_focus_targets.append_array(_footer_buttons)
func _focus_index(i: int) -> void:
if _focus_targets.is_empty():
return
_index = wrapi(i, 0, _focus_targets.size())
_focus_targets[_index].grab_focus()
func _activate_focus() -> void:
if _focus_targets.is_empty():
return
_focus_targets[_index].pressed.emit()
func open(campaign: Dictionary = {}) -> void:
_campaign = campaign.duplicate(true)
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
var next_id := str(_campaign.get("next_venue", ""))
var complete := bool(_campaign.get("complete", false))
if _status:
if complete:
_status.text = Strings.lookup("ui.campaign_complete")
elif next_id != "":
_status.text = Strings.fmt("ui.campaign_status", [int(_campaign.get("shift_number", 1)), _venue_label(next_id)])
if _continue_button:
var next_label := _venue_label(next_id)
_continue_button.text = Strings.fmt("ui.continue_shift", [next_label]) if next_id != "" else Strings.lookup("ui.campaign_complete_short")
_continue_button.visible = not complete
_refresh_focus_targets()
_focus_index(_index)
func _venue_label(id: String) -> String:
for venue in VENUES:
if str(venue["id"]) == id:
return Strings.lookup(str(venue["label_key"]))
return id.replace("_", " ").capitalize()
func _on_continue_pressed() -> void:
close()
continue_requested.emit()
func close() -> void:
visible = false
_root.visible = false
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.echo:
return
var key := event as InputEventKey
if event.is_action_pressed("ui_up") or (key != null and key.pressed and key.keycode == KEY_W):
_focus_index(_index - 1)
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_down") or (key != null and key.pressed and key.keycode == KEY_S):
_focus_index(_index + 1)
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_accept"):
_activate_focus()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_cancel"):
quit_requested.emit()
get_viewport().set_input_as_handled()
func _on_venue_pressed(i: int) -> void:
if i < 0 or i >= VENUES.size():
return
var venue: Dictionary = VENUES[i]
close()
venue_selected.emit(str(venue["id"]))