From ddc7f569a6945a8f951b9b86146fcc9185248d6b Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Fri, 9 Oct 2026 22:25:35 -0700 Subject: [PATCH 1/2] feat(input): show the right controller prompts and pause when the player's pad disconnects [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Booth players use whatever pads are on the table (#46). A new ControllerService autoload, built on the vendored CodeRabbit Kit, treats the pad that last sent input as player one: - Prompts name that pad's buttons: the same bottom button reads A on Xbox, Cross on PlayStation and B on Nintendo. Labels come from the live bindings and update when the player picks up a pad of another family; with no pad connected, prompts show the keys alone. UI scripts use the preloadable app/controller_prompts.gd, because suites compile UI before autoloads exist. - If that pad disconnects during a game, the service sends the game its own `pause` action, so each mode opens its own pause menu, and a banner asks for the pad back. Reconnecting the same pad resumes; Start on another pad also resumes (Start is the pause toggle) and makes it player one. A game the player paused stays paused. A disconnect in a menu frees player one. - Godot ships ui_accept and ui_cancel without pad buttons, so pads could not confirm or go back in menus although the prompts said "A" and "B". The bottom face button now confirms and the right one goes back, on every pad. - Every binding answers every pad: the service widens any single-device pad binding, and the beat 'em up's helper now delegates to the kit. tests/controller_service.gd drives all of this with fake pads in the platformer, the beat 'em up, the runner and the hub. Two existing suites change: code_city_smoke releases GUI focus around its pad-mapping presses (the bottom button now also turns the focused intro page, as Space does), and heroes_villains_logic builds the expected pickup prompt instead of hard-coding "E / Y". --- .github/workflows/ci.yml | 1 + .../app/controller_prompts.gd | 35 +++ .../app/controller_prompts.gd.uid | 1 + .../app/controller_service.gd | 203 ++++++++++++++++ .../app/controller_service.gd.uid | 1 + .../modes/beatem_up/catalog.gd | 8 +- .../modes/beatem_up/game.gd | 13 +- .../modes/beatem_up/reinvent/game.gd | 16 +- hoppys-adventure-arcade/project.godot | 1 + .../scenes/beatem_up/beatem_menu.gd | 8 +- .../scenes/main_menu/main_menu.gd | 12 +- .../scenes/runner/runner_menu.gd | 14 +- .../scenes/runner/ui/pause_menu.gd | 6 +- .../scenes/runner/ui/runner_hud.gd | 13 +- .../tests/code_city_smoke.gd | 6 + .../tests/controller_service.gd | 218 ++++++++++++++++++ .../tests/controller_service.gd.uid | 1 + .../tests/heroes_villains_logic.gd | 4 +- .../tests/support/fake_pads.gd | 34 +++ .../tests/support/fake_pads.gd.uid | 1 + 20 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 hoppys-adventure-arcade/app/controller_prompts.gd create mode 100644 hoppys-adventure-arcade/app/controller_prompts.gd.uid create mode 100644 hoppys-adventure-arcade/app/controller_service.gd create mode 100644 hoppys-adventure-arcade/app/controller_service.gd.uid create mode 100644 hoppys-adventure-arcade/tests/controller_service.gd create mode 100644 hoppys-adventure-arcade/tests/controller_service.gd.uid create mode 100644 hoppys-adventure-arcade/tests/support/fake_pads.gd create mode 100644 hoppys-adventure-arcade/tests/support/fake_pads.gd.uid diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69c73920..e7f8c583 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: code_city_logic code_city_motion code_city_smoke + controller_service stage_flow beatemup_knockout beatemup_story diff --git a/hoppys-adventure-arcade/app/controller_prompts.gd b/hoppys-adventure-arcade/app/controller_prompts.gd new file mode 100644 index 00000000..159a3c8e --- /dev/null +++ b/hoppys-adventure-arcade/app/controller_prompts.gd @@ -0,0 +1,35 @@ +extends RefCounted +## Controller-aware prompt text for UI scripts. Preload this instead of naming the ControllerService +## autoload: suites preload UI scripts before autoloads exist, and a bare autoload name does not +## compile then. Without the service (or a controller), prompts show the keys alone. + + +static func service() -> Node: + var tree := Engine.get_main_loop() as SceneTree + return tree.root.get_node_or_null(^"ControllerService") if tree else null + + +## The label of an action's first pad binding for the player's controller, or "". +static func pad(action: StringName) -> String: + var controllers := service() + return controllers.pad(action) if controllers else "" + + +## "Space / Cross" with a controller connected, "Space" without one. +static func keys_or_pad(keys: String, pad_label: String) -> String: + return keys if pad_label.is_empty() else "%s / %s" % [keys, pad_label] + + +## keys_or_pad() for an action: `either("Space", &"jump")`. +static func either(keys: String, action: StringName, upper: bool = false) -> String: + var label := pad(action) + return keys_or_pad(keys, label.to_upper() if upper else label) + + +## Sets a label's text now and whenever the player's controller family changes. +static func track(label: Label, build: Callable) -> void: + var controllers := service() + if controllers: + controllers.track(label, build) + else: + label.text = build.call() diff --git a/hoppys-adventure-arcade/app/controller_prompts.gd.uid b/hoppys-adventure-arcade/app/controller_prompts.gd.uid new file mode 100644 index 00000000..ebd721ff --- /dev/null +++ b/hoppys-adventure-arcade/app/controller_prompts.gd.uid @@ -0,0 +1 @@ +uid://dhqxfnbifn5ja diff --git a/hoppys-adventure-arcade/app/controller_service.gd b/hoppys-adventure-arcade/app/controller_service.gd new file mode 100644 index 00000000..2eb1948f --- /dev/null +++ b/hoppys-adventure-arcade/app/controller_service.gd @@ -0,0 +1,203 @@ +extends Node +## Which controller the player is using, what its buttons are called, and what happens when it +## disconnects. +## +## Every pad binding answers every controller (device -1), so any pad on the table can play. The +## pad that last sent input is player one: prompts name its buttons ("Cross", "A" or "B" for the +## same bottom button). If that pad disconnects during a game, the game pauses through its own +## pause menu and a banner asks for it back; the game resumes when the pad reconnects. Start on +## another pad also resumes, because Start is the game's pause toggle, and that pad becomes player +## one. A disconnect anywhere else just frees player one for the next pad that is used. + +## Prompts should be rebuilt: the player switched to a pad of another family, or the last pad left. +signal family_changed(family: String) + +const CrInput := preload("res://addons/cr_kit/input.gd") +const MenuStyle := preload("res://scenes/main_menu/menu_style.gd") +const Labels := CrInput.Labels +const PLAYER := &"p1" +## AppRouter routes whose scene treats the `pause` action as its pause toggle. The rest are menus. +## tests/controller_service.gd fails when a route is in neither list. +const GAMEPLAY_ROUTES: Array[String] = [ + "heroes_vs_villains", "code_city_brawl", "enchanted_repo", "orbital_ops", "bay_area_blitz", "runner_game"] +const MENU_ROUTES: Array[String] = ["hub", "platformer", "runner", "beatem_up"] +## Godot ships ui_accept and ui_cancel without pad buttons, so a pad could not confirm or go back +## in menus. These match the prompts: the bottom face button confirms, the right one goes back. +const MENU_BINDINGS := { + &"ui_accept": {"buttons": [JOY_BUTTON_A]}, + &"ui_cancel": {"buttons": [JOY_BUTTON_B]}, +} +const STICK_THRESHOLD := 0.5 + +var roster: RefCounted +## The player's controller family, or "" when no controller is connected (keyboard prompts only). +var family := "" +var banner: CanvasLayer +var _banner_text: Label +var _paused_for_controller := false + + +func _ready() -> void: + # Disconnect handling has to run while a game is paused. + process_mode = Node.PROCESS_MODE_ALWAYS + CrInput.Bindings.install(MENU_BINDINGS) + CrInput.Bindings.widen_to_any_device() + _build_banner() + use_hardware(null) + Input.joy_connection_changed.connect(_on_joy_connection_changed) + + +## Replaces the controller source; tests pass a fake with the kit roster's Hardware methods. +func use_hardware(hardware: RefCounted) -> void: + roster = CrInput.Roster.new([PLAYER], hardware) + roster.role_assigned.connect(_on_role_assigned) + roster.role_lost.connect(_on_role_lost) + roster.role_restored.connect(_on_role_restored) + _set_paused_for_controller(false) + roster.refresh() + _update_family() + + +## The label of an action's first pad binding for the current controller, or "" without one. +func pad(action: StringName) -> String: + return "" if family.is_empty() else Labels.action(action, family) + + +## Sets a label's text now and again whenever the controller family changes, for as long as the +## label exists. +func track(label: Label, build: Callable) -> void: + label.text = build.call() + var refresh := func(_family: String) -> void: + if is_instance_valid(label): label.text = build.call() + family_changed.connect(refresh) + label.tree_exiting.connect(func() -> void: + if family_changed.is_connected(refresh): family_changed.disconnect(refresh)) + + +## True while a game is paused because its player's controller disconnected. +func is_paused_for_controller() -> bool: + return _paused_for_controller + + +func _input(event: InputEvent) -> void: + var device := _gameplay_device(event) + if device < 0 or roster.role_of(device) == PLAYER: + return + if _paused_for_controller: + # Only a deliberate Start takes over a game paused for its missing controller. Start is also + # the pause toggle, so the same press resumes the game through its own pause menu. + if event is InputEventJoypadButton and event.button_index == JOY_BUTTON_START and roster.claim(device): + _set_paused_for_controller(false) + elif roster.is_waiting(PLAYER): + roster.claim(device) + else: + roster.assign(PLAYER, device) + + +func _process(_delta: float) -> void: + # The player resumed (or left the game) another way: nothing is waiting on the pad any more. + if _paused_for_controller and not get_tree().paused: + _set_paused_for_controller(false) + + +func _gameplay_device(event: InputEvent) -> int: + if event is InputEventJoypadButton and event.pressed: + return event.device + if event is InputEventJoypadMotion and absf(event.axis_value) >= STICK_THRESHOLD: + return event.device + return -1 + + +func _on_joy_connection_changed(device: int, connected: bool) -> void: + roster.on_joy_connection_changed(device, connected) + + +func _on_role_assigned(_role: StringName, _device: int) -> void: + _update_family() + + +func _on_role_lost(_role: StringName, _device: int) -> void: + _update_family() + if not _in_gameplay(): + roster.release(PLAYER) + else: + # Connection callbacks can arrive while Input is dispatching; pause once it is idle. + _pause_for_controller.call_deferred() + + +func _on_role_restored(_role: StringName, _device: int) -> void: + _update_family() + _resume_for_controller.call_deferred() + + +func _pause_for_controller() -> void: + # A game already paused by its player stays paused, and a reconnect won't resume it. + if roster.is_waiting(PLAYER) and _in_gameplay() and not get_tree().paused: + _toggle_pause() + _set_paused_for_controller(get_tree().paused) + + +func _resume_for_controller() -> void: + if _paused_for_controller and get_tree().paused: + _toggle_pause() + _set_paused_for_controller(false) + + +func _set_paused_for_controller(value: bool) -> void: + _paused_for_controller = value + banner.visible = value + + +func _update_family() -> void: + var device: int = roster.device_for(PLAYER) + var current: String = roster.family(device) if device >= 0 else "" + if current != family: + family = current + family_changed.emit(family) + + +func _in_gameplay() -> bool: + var scene := get_tree().current_scene + if scene == null: + return false + for route in GAMEPLAY_ROUTES: + if AppRouter.ROUTES[route] == scene.scene_file_path: + return true + return false + + +## Each game owns its pause menu and toggles it on the `pause` action, so send that action +## rather than pausing the tree underneath it. +func _toggle_pause() -> void: + for pressed in [true, false]: + var action := InputEventAction.new() + action.action = &"pause" + action.pressed = pressed + Input.parse_input_event(action) + Input.flush_buffered_events() + + +func _build_banner() -> void: + banner = CanvasLayer.new() + banner.layer = 90 # Above every game's pause menu, below the router's fade (100). + banner.visible = false + add_child(banner) + var panel := PanelContainer.new() + panel.theme = MenuStyle.theme() + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP) + panel.position.y = 24 + var box := StyleBoxFlat.new() + box.bg_color = Color("142735") + box.border_color = MenuStyle.MINT + box.set_border_width_all(2) + box.set_content_margin_all(14) + panel.add_theme_stylebox_override("panel", box) + banner.add_child(panel) + _banner_text = Label.new() + _banner_text.text = "CONTROLLER DISCONNECTED\nReconnect it to resume, or press Start on another controller." + _banner_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _banner_text.add_theme_font_size_override("font_size", 18) + _banner_text.add_theme_color_override("font_color", MenuStyle.CREAM) + panel.add_child(_banner_text) + panel.resized.connect(func() -> void: panel.position.x = (panel.get_viewport_rect().size.x - panel.size.x) / 2.0) diff --git a/hoppys-adventure-arcade/app/controller_service.gd.uid b/hoppys-adventure-arcade/app/controller_service.gd.uid new file mode 100644 index 00000000..95f50d39 --- /dev/null +++ b/hoppys-adventure-arcade/app/controller_service.gd.uid @@ -0,0 +1 @@ +uid://qm67od4v1o33 diff --git a/hoppys-adventure-arcade/modes/beatem_up/catalog.gd b/hoppys-adventure-arcade/modes/beatem_up/catalog.gd index 8f61405f..7ab95cd7 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/catalog.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/catalog.gd @@ -101,14 +101,12 @@ const WAVES := [ [["merge_conflict_soldier","force_push_bandit"], ["stale_branch_zombie","force_push_bandit","merge_conflict_soldier"], []]] ## InputMap's "any device" id. A new InputEventJoypadButton defaults to device 0, so a code-made ## binding would answer only the first pad; booths and reconnected controllers use other ids. -const ANY_DEVICE := -1 +const Bindings := preload("res://addons/cr_kit/input.gd").Bindings +const ANY_DEVICE := Bindings.ANY_DEVICE const PAD_BUTTONS := { "brawl_jump":JOY_BUTTON_A,"brawl_pickup":JOY_BUTTON_Y,"brawl_attack":JOY_BUTTON_X} static func joy_button(button: JoyButton) -> InputEventJoypadButton: - var event := InputEventJoypadButton.new() - event.device = ANY_DEVICE - event.button_index = button - return event + return Bindings.joy_button(button) static func install_input() -> void: for action in ["brawl_jump","brawl_pickup","brawl_attack"]: if InputMap.has_action(action): continue diff --git a/hoppys-adventure-arcade/modes/beatem_up/game.gd b/hoppys-adventure-arcade/modes/beatem_up/game.gd index ca7aa5f6..99e92925 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/game.gd @@ -16,6 +16,7 @@ const MINIMAP_STATES := ["stage_enter","combat","stage_exit","finisher","knockou ## Results and game-over menus arm this long after appearing (see _input). const OVERLAY_ARM := .5 const ARMED_OVERLAYS := ["won","game_over"] +const Prompts := preload("res://app/controller_prompts.gd") var rng := RandomNumberGenerator.new() var stage_index := 0 var room := 0 @@ -432,7 +433,7 @@ func _tick_items(dt: float) -> void: item.sprite.position.y = -22+sin(item.age*4)*4 var distance: float = item.node.position.distance_to(player.position) if Catalog.WEAPONS.has(item.kind): - if distance<85: prompt.text = "E / Y · PICK UP "+item.kind.replace("_"," ").to_upper() + if distance<85: prompt.text = Prompts.either("E", &"brawl_pickup", true)+" · PICK UP "+item.kind.replace("_"," ").to_upper() elif distance < 45 and player.height < 20 and player.hp>0: player.apply_effect(item.kind) sound("pickup",player.position) @@ -694,7 +695,13 @@ func _build_ui() -> void: prompt.offset_bottom = -54 prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER ui.add_child(prompt) - var controls := Style.label("WASD / stick · Move Space / A · Jump J, X / X · Combo E / Y · Pickup Esc · Pause",13) + var controls := Style.label("",13) + Prompts.track(controls, func() -> String: + return "WASD / stick · Move %s · Jump %s · Combo %s · Pickup %s · Pause" % [ + Prompts.either("Space", &"brawl_jump"), + Prompts.either("J, X", &"brawl_attack"), + Prompts.either("E", &"brawl_pickup"), + Prompts.either("Esc", &"pause")]) controls.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE) controls.offset_top = -27 controls.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER @@ -851,7 +858,7 @@ func _input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("pause") and not event.is_echo(): - if state in PAUSABLE: pause("Chain three attacks for a knockback finisher. Jump or change lanes to dodge.\nApproach a weapon and press E / Y to equip it.") + if state in PAUSABLE: pause("Chain three attacks for a knockback finisher. Jump or change lanes to dodge.\nApproach a weapon and press %s to equip it." % Prompts.either("E", &"brawl_pickup")) elif state=="paused": resume() get_viewport().set_input_as_handled() diff --git a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd index 32e2817a..4f8051df 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd @@ -14,6 +14,7 @@ const MINIMAP_STATES := ["stage_enter","combat","stage_exit","boss_victory","kno const PAUSABLE := ["combat","cards","boss_victory","stage_enter","stage_exit","knockout"] const TERMINAL_OVERLAY_ARM := .5 const CARD_HINT := "Enter / View · Skip Esc / Start · Pause" +const Prompts := preload("res://app/controller_prompts.gd") var rng := RandomNumberGenerator.new() var stage_index := 0 var room := 0 @@ -460,7 +461,7 @@ func _tick_items(dt: float) -> void: item.sprite.position.y = -22+sin(item.age*4)*4 var distance: float = item.node.position.distance_to(player.position) if Catalog.WEAPONS.has(item.kind): - if distance<85: prompt.text = "E / Y · PICK UP "+item.kind.replace("_"," ").to_upper() + if distance<85: prompt.text = Prompts.either("E", &"brawl_pickup", true)+" · PICK UP "+item.kind.replace("_"," ").to_upper() elif distance < 45 and player.height < 20 and player.hp>0: player.apply_effect(item.kind) sound("pickup",player.position) @@ -682,7 +683,14 @@ func _build_ui() -> void: prompt.offset_bottom = -54 prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER ui.add_child(prompt) - var controls := Style.label("WASD / stick · Move Space / A · Jump J, X / X · Combo E / Y · Pickup Hold Q / LB · REVIEW Esc / Start · Pause",13) + var controls := Style.label("",13) + Prompts.track(controls, func() -> String: + return "WASD / stick · Move %s · Jump %s · Combo %s · Pickup Hold %s · REVIEW %s · Pause" % [ + Prompts.either("Space", &"brawl_jump"), + Prompts.either("J, X", &"brawl_attack"), + Prompts.either("E", &"brawl_pickup"), + Prompts.either("Q", &"review_burst"), + Prompts.either("Esc", &"pause")]) controls.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE) controls.offset_top = -27 controls.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER @@ -745,7 +753,7 @@ func _update_hud() -> void: var caps := "" for key in capabilities: caps += ("● " if capabilities[key] else "○ ")+key.replace("_"," ").to_upper()+" " - if capabilities.review: caps += " Q / LB: "+("READY" if player.review_cooldown<=0 else "%ds" % ceili(player.review_cooldown)) + if capabilities.review: caps += " %s: " % Prompts.either("Q", &"review_burst")+("READY" if player.review_cooldown<=0 else "%ds" % ceili(player.review_cooldown)) if player.shield: caps += " SHIELD" if not player.effects.is_empty(): caps += "\n" for effect in player.effects: caps += " "+effect.replace("_"," ").to_upper()+" %ds" % ceili(player.effects[effect]) @@ -828,7 +836,7 @@ func _unhandled_input(event: InputEvent) -> void: elif state in PAUSABLE: pause_return_state=state playing=false;state="paused";get_tree().paused=true - show_overlay("REVIEW PAUSED","Chain attacks, sidestep warnings, and hold Q / LB to charge REVIEW.","Resume",resume) + show_overlay("REVIEW PAUSED","Chain attacks, sidestep warnings, and hold %s to charge REVIEW." % Prompts.either("Q", &"review_burst"),"Resume",resume) get_viewport().set_input_as_handled() elif state=="cards" and event.is_action_pressed("heroes_popup_advance") and not event.is_echo(): if card_clock>.25: next_card() diff --git a/hoppys-adventure-arcade/project.godot b/hoppys-adventure-arcade/project.godot index d30805ef..ae02e644 100644 --- a/hoppys-adventure-arcade/project.godot +++ b/hoppys-adventure-arcade/project.godot @@ -21,6 +21,7 @@ config/icon="res://icon.svg" SaveService="*res://app/save_service.gd" AudioService="*res://app/audio_service.gd" AppRouter="*res://app/app_router.gd" +ControllerService="*res://app/controller_service.gd" [display] diff --git a/hoppys-adventure-arcade/scenes/beatem_up/beatem_menu.gd b/hoppys-adventure-arcade/scenes/beatem_up/beatem_menu.gd index 61e14644..5e553f6d 100644 --- a/hoppys-adventure-arcade/scenes/beatem_up/beatem_menu.gd +++ b/hoppys-adventure-arcade/scenes/beatem_up/beatem_menu.gd @@ -4,6 +4,7 @@ const Catalog = preload("res://modes/beatem_up/catalog.gd") const Heroes = preload("res://modes/beatem_up/reinvent/catalog.gd") const Art = preload("res://modes/beatem_up/art.gd") const HeroArt = preload("res://modes/beatem_up/reinvent/art.gd") +const Prompts := preload("res://app/controller_prompts.gd") var backdrop: TextureRect var caption: Label var description: Label @@ -57,7 +58,12 @@ func _ready() -> void: var quit := Style.button("Quit Game",48) column.add_child(quit) quit.pressed.connect(func(): AppRouter.quit_game()) - column.add_child(Style.label("WASD / stick: move · Space / A: jump · J or X / X: combo\nE / Y: pick up weapon · Esc / Start: pause\nHeroes: hold Q / LB to charge REVIEW after unlocking it",14)) + var controls := Style.label("",14) + Prompts.track(controls, func() -> String: + return "WASD / stick: move · %s: jump · %s: combo\n%s: pick up weapon · %s: pause\nHeroes: hold %s to charge REVIEW after unlocking it" % [ + Prompts.either("Space", &"brawl_jump"), Prompts.either("J or X", &"brawl_attack"), + Prompts.either("E", &"brawl_pickup"), Prompts.either("Esc", &"pause"), Prompts.either("Q", &"review_burst")]) + column.add_child(controls) caption=Style.label("",16,Color("ffb366")) column.add_child(caption) local_music=AudioStreamPlayer.new() diff --git a/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd b/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd index 4c8c08b1..d70326a3 100644 --- a/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd +++ b/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd @@ -1,6 +1,7 @@ extends Node2D const Style = preload("res://scenes/main_menu/menu_style.gd") const Preview = preload("res://scenes/main_menu/preview.gd") +const Prompts := preload("res://app/controller_prompts.gd") @onready var showcase = $Showcase @onready var hoppy = $Interface/Hoppy var ui: Control @@ -89,7 +90,9 @@ func _ready() -> void: for i in buttons.size(): buttons[i].focus_neighbor_top = buttons[i].get_path_to(buttons[(i - 1 + buttons.size()) % buttons.size()]) buttons[i].focus_neighbor_bottom = buttons[i].get_path_to(buttons[(i + 1) % buttons.size()]) - var footer := Style.label("↑ ↓ SELECT ENTER / A CONFIRM", 12, Color("9baebc")) + var footer := Style.label("", 12, Color("9baebc")) + Prompts.track(footer, func() -> String: + return "↑ ↓ SELECT %s CONFIRM" % Prompts.either("ENTER", &"ui_accept", true)) footer.position = Vector2(76, 670) ui.add_child(footer) caption = Style.label("", 21) @@ -255,7 +258,12 @@ func _open_settings(invoker: Control) -> void: fullscreen.toggled.connect(func(value): SaveService.values.fullscreen = value SaveService.apply_display()) - column.add_child(Style.label("Arrows / D-pad / stick: select\nEnter / A: confirm • Esc / B: back", 13, Color("afbdc9"))) + var hint := Style.label("", 13, Color("afbdc9")) + Prompts.track(hint, func() -> String: + return "Arrows / D-pad / stick: select\n%s: confirm • %s: back" % [ + Prompts.either("Enter", &"ui_accept"), + Prompts.either("Esc", &"ui_cancel")]) + column.add_child(hint) var close := Style.button("Save & return", 45) column.add_child(close) close.pressed.connect(_close_modal) diff --git a/hoppys-adventure-arcade/scenes/runner/runner_menu.gd b/hoppys-adventure-arcade/scenes/runner/runner_menu.gd index 3de7a0f9..1a31db0f 100644 --- a/hoppys-adventure-arcade/scenes/runner/runner_menu.gd +++ b/hoppys-adventure-arcade/scenes/runner/runner_menu.gd @@ -5,6 +5,7 @@ const Style = preload("res://scenes/runner/ui/hud_style.gd") const Leaderboard = preload("res://scenes/runner/systems/runner_leaderboard.gd") const GpuWarmup = preload("res://scenes/runner/warmup/gpu_warmup.gd") const Music = preload("res://Assets/Backgrounds/cyberpunk-street-files/Assets/music/cyberpunk-street.ogg") +const Prompts := preload("res://app/controller_prompts.gd") var leaderboard_path := Leaderboard.DEFAULT_PATH var leaderboard_path_at_open := Leaderboard.DEFAULT_PATH @@ -78,16 +79,22 @@ func _ready() -> void: controls.add_theme_constant_override("separation", 8) card.add_child(controls) controls.add_child(Style.label("KEEP THE BUILD MOVING", 26, Style.MINT)) - controls.add_child(Style.label("A / D · ← → Change lane\nW / SPACE / ↑ Jump\nS / CTRL / ↓ Roll\nESC / START Pause", 22)) + var keys := Style.label("", 22) + Prompts.track(keys, func() -> String: + return "A / D · ← → Change lane\nW / SPACE / ↑ Jump\nS / CTRL / ↓ Roll\n%s Pause" % Prompts.either("ESC", &"pause", true)) + controls.add_child(keys) controls.add_child(Style.teaching_legend(true)) controls.add_child(Style.label("Two hits without recovery\nand the backlog catches you.", 18)) - _label("↑ ↓ / D-PAD SELECT ENTER / A CONFIRM ESC / B BACK", 12, Vector2(76, 670), Color("9baebc")) + Prompts.track(_label("", 12, Vector2(76, 670), Color("9baebc")), func() -> String: + return "↑ ↓ / D-PAD SELECT %s CONFIRM %s BACK" % [ + Prompts.either("ENTER", &"ui_accept", true), + Prompts.either("ESC", &"ui_cancel", true)]) buttons[0].grab_focus() _process(0.0) # Compile every gameplay shader now, behind the opaque route veil, instead of on first sight. GpuWarmup.run(self, camera) -func _label(text: String, size: int, position_2d: Vector2, color: Color = Style.CREAM) -> void: +func _label(text: String, size: int, position_2d: Vector2, color: Color = Style.CREAM) -> Label: var label := Style.label(text, size, color) var backing := PanelContainer.new() backing.position = position_2d - Vector2(14, 8) @@ -96,6 +103,7 @@ func _label(text: String, size: int, position_2d: Vector2, color: Color = Style. ui.add_child(backing) backing.add_child(label) if size == 60: title_label = label + return label func _panel_style() -> StyleBoxFlat: var box := Style.panel(Style.MINT, 0.96) diff --git a/hoppys-adventure-arcade/scenes/runner/ui/pause_menu.gd b/hoppys-adventure-arcade/scenes/runner/ui/pause_menu.gd index 6375735e..0d1f5713 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/pause_menu.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/pause_menu.gd @@ -3,6 +3,7 @@ extends CanvasLayer ## It deliberately does NOT bind ui_cancel: RunnerGame owns the `pause` action. const Style = preload("res://scenes/runner/ui/hud_style.gd") +const Prompts := preload("res://app/controller_prompts.gd") var game: Node var resume_button: Button @@ -45,7 +46,10 @@ func _ready() -> void: column.add_child(menu) buttons.append(menu) menu.pressed.connect(func() -> void: _call_game(&"go_to_menu")) - column.add_child(Style.label("ESC / START resume", 20, Style.MINT)) + var resume_hint := Style.label("", 20, Style.MINT) + Prompts.track(resume_hint, func() -> String: + return "%s resume" % Prompts.either("ESC", &"pause", true)) + column.add_child(resume_hint) for i in buttons.size(): buttons[i].focus_neighbor_top = buttons[i].get_path_to(buttons[(i - 1 + buttons.size()) % buttons.size()]) buttons[i].focus_neighbor_bottom = buttons[i].get_path_to(buttons[(i + 1) % buttons.size()]) diff --git a/hoppys-adventure-arcade/scenes/runner/ui/runner_hud.gd b/hoppys-adventure-arcade/scenes/runner/ui/runner_hud.gd index 6258c687..4864bdb1 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/runner_hud.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/runner_hud.gd @@ -10,6 +10,7 @@ const Burst = preload("res://scenes/runner/powerups/visuals/hifi/collect_burst.g ## Every glyph HUD copy can use; drawn once at ready per toast/chip font size so a first toast or ## chip never rasterises and uploads new glyphs on the pickup frame. const WARM_TEXT := "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 ×\u2014+-:/.,!'" +const Prompts := preload("res://app/controller_prompts.gd") var turn_fx: Node3D var turn_panel: PanelContainer var turn_label: Label @@ -147,8 +148,16 @@ func _ready() -> void: get_ready.name = "GetReady" get_ready.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER ready_box.add_child(get_ready) - for line in ["A / D · ← / → lane SPACE / A jump", "S / ↓ / B roll ESC / START pause"]: - var hint := Style.label(line, 22) + var lines: Array[Callable] = [ + func() -> String: + return "A / D · ← / → lane %s jump" % Prompts.either("SPACE", &"jump", true), + func() -> String: + return "%s roll %s pause" % [Prompts.either("S / ↓", &"roll", true), + Prompts.either("ESC", &"pause", true)], + ] + for line in lines: + var hint := Style.label("", 22) + Prompts.track(hint, line) hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER ready_box.add_child(hint) ready_box.add_child(Style.teaching_legend()) diff --git a/hoppys-adventure-arcade/tests/code_city_smoke.gd b/hoppys-adventure-arcade/tests/code_city_smoke.gd index f4bb047b..9575c889 100644 --- a/hoppys-adventure-arcade/tests/code_city_smoke.gd +++ b/hoppys-adventure-arcade/tests/code_city_smoke.gd @@ -47,6 +47,10 @@ func run() -> void: game.player.tick(.05) check(game.player.position.y void: game.player.tick(.02) check(game.player.swing_left>0,"Gamepad X mapping attacks") attack_event.pressed=false;Input.parse_input_event(attack_event) + await process_frame + if focused: focused.grab_focus() game.player.controlled=false var max_enemies := 0 var max_bullets := 0 diff --git a/hoppys-adventure-arcade/tests/controller_service.gd b/hoppys-adventure-arcade/tests/controller_service.gd new file mode 100644 index 00000000..2c301e07 --- /dev/null +++ b/hoppys-adventure-arcade/tests/controller_service.gd @@ -0,0 +1,218 @@ +extends SceneTree +## ControllerService with fake pads: prompts follow the pad in use, a game pauses through its own +## pause menu when that pad disconnects and resumes when it returns, and every pad binding answers +## every controller. No real devices are read. +const CrInput := preload("res://addons/cr_kit/input.gd") +const FakePads := preload("res://tests/support/fake_pads.gd") +const Prompts := preload("res://app/controller_prompts.gd") +const XBOX := ["Xbox Series X Controller", "xbox-guid", 0x045e] +const DUALSENSE := ["DualSense Wireless Controller", "sony-guid", 0x054c] +const SWITCH_PRO := ["Nintendo Switch Pro Controller", "nintendo-guid", 0x057e] +var failures := 0 +var service: Node +var pads: RefCounted + + +func _initialize() -> void: + call_deferred("run") + + +func check(ok: bool, message: String) -> void: + if not ok: + failures += 1 + push_error(message) + + +func frames(count: int) -> void: + for i in count: await physics_frame + + +## Connection changes reach the service the way Input reports them; it pauses or resumes a game on +## the next idle step, so callers await the frame. +func plug(device: int, pad: Array) -> void: + pads.plug(device, pad[0], pad[1], pad[2]) + service._on_joy_connection_changed(device, true) + await process_frame + + +func unplug(device: int) -> void: + pads.unplug(device) + service._on_joy_connection_changed(device, false) + await process_frame + + +func press(device: int, button: JoyButton) -> void: + for pressed in [true, false]: + var event := InputEventJoypadButton.new() + event.device = device + event.button_index = button + event.pressed = pressed + Input.parse_input_event(event) + Input.flush_buffered_events() + + +func fresh_pads() -> void: + pads = FakePads.new() + service.use_hardware(pads) + + +func run() -> void: + service = root.get_node("ControllerService") + routes_are_classified() + every_pad_binding_answers_every_controller() + await menus_accept_any_pad() + await prompts_follow_the_pad_in_use() + await platformer_pauses_and_resumes() + await brawler_hands_over_to_another_pad() + await runner_pauses_and_resumes() + await menu_disconnect_frees_player_one() + print("CONTROLLER SERVICE: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures)") + current_scene.queue_free() + await frames(5) + var audio := root.get_node("AudioService") + if audio.has_method("settle"): + await audio.settle() + else: + audio.shutdown() + await create_timer(0.3).timeout + quit(failures) + + +func routes_are_classified() -> void: + var classified: Array[String] = service.GAMEPLAY_ROUTES + service.MENU_ROUTES + classified.sort() + var routes: Array = root.get_node("AppRouter").ROUTES.keys() + routes.sort() + check(classified == routes, "Every AppRouter route is gameplay or menu: %s vs %s" % [classified, routes]) + + +func every_pad_binding_answers_every_controller() -> void: + # The modes install their own actions on first use; include them. + preload("res://modes/beatem_up/reinvent/catalog.gd").install_input() + var found: PackedStringArray = CrInput.Bindings.single_device_pad_events() + check(found.is_empty(), "Pad bindings that answer one controller only: %s" % [found]) + + +func menus_accept_any_pad() -> void: + fresh_pads() + await plug(3, XBOX) + var button := Button.new() + root.add_child(button) + var presses := [0] + button.pressed.connect(func() -> void: presses[0] += 1) + button.grab_focus() + await frames(1) + press(3, JOY_BUTTON_A) + await frames(1) + check(presses[0] == 1, "The bottom face button on pad 3 presses a focused menu button") + button.queue_free() + check(service.pad(&"ui_cancel") == "B", "The right face button goes back") + + +func prompts_follow_the_pad_in_use() -> void: + fresh_pads() + check(service.family == "" and service.pad(&"jump") == "", "No controller: no pad prompt") + check(Prompts.either("Space", &"jump") == "Space", "Keyboard-only prompt") + var label := Label.new() + root.add_child(label) + service.track(label, func() -> String: return Prompts.either("Space", &"jump")) + await plug(0, XBOX) + check(service.family == "xbox" and label.text == "Space / A", "Xbox prompt, got %s" % label.text) + await plug(1, DUALSENSE) + check(service.family == "xbox", "Connecting a second pad does not switch the prompts") + press(1, JOY_BUTTON_X) + check(service.family == "playstation" and label.text == "Space / Cross", + "The pad last used drives the prompts, got %s" % label.text) + await plug(2, SWITCH_PRO) + press(2, JOY_BUTTON_DPAD_DOWN) + check(label.text == "Space / B", "Nintendo's bottom button reads B, got %s" % label.text) + check(service.pad(&"roll") == "A" and service.pad(&"attack") == "Y" and service.pad(&"pause") == "+", + "Nintendo labels for the right, left and Start buttons") + await unplug(2) + await unplug(1) + await unplug(0) + check(service.family == "" and label.text == "Space", "Prompts drop pad labels with no pad left") + label.free() + check(service.family_changed.get_connections().is_empty(), "A freed label stops tracking") + + +func platformer_pauses_and_resumes() -> void: + change_scene_to_file("res://scenes/platformer/bay_area_blitz.tscn") + await frames(30) + var level := current_scene + level._resume() + fresh_pads() + await plug(0, XBOX) + await frames(5) + await unplug(0) + check(paused and level.overlay.visible, "A disconnect pauses the platformer through its pause menu") + check(service.is_paused_for_controller() and service.banner.visible, "The banner asks for the controller") + await plug(4, XBOX) # The same pad returns under a new id. + check(not paused and not level.overlay.visible, "Reconnecting resumes the platformer") + check(not service.banner.visible and service.family == "xbox", "Banner hidden, prompts back") + await frames(5) + # A game the player paused stays paused through a disconnect and reconnect. + level._pause() + await unplug(4) + check(paused and not service.is_paused_for_controller(), "A player-paused game is left alone") + await plug(5, XBOX) + check(paused and level.overlay.visible, "Reconnecting does not resume a game the player paused") + level._resume() + await frames(5) + + +func brawler_hands_over_to_another_pad() -> void: + change_scene_to_file("res://scenes/beatem_up/code_city_brawl.tscn") + await frames(3) + var game := current_scene + game.test_mode = true + game.begin_play() + await frames(5) + fresh_pads() + await plug(0, XBOX) + await plug(1, DUALSENSE) + press(0, JOY_BUTTON_A) + await unplug(0) + check(paused and game.state == "paused", "A disconnect pauses the beat 'em up (state %s)" % game.state) + press(1, JOY_BUTTON_START) + check(not paused and game.state != "paused", "Start on another pad resumes (state %s)" % game.state) + check(service.family == "playstation" and not service.banner.visible, "That pad is now player one") + await frames(5) + # The pause menu itself answers every pad: the focused Resume button takes the bottom button. + await plug(2, SWITCH_PRO) + await unplug(1) + check(paused and service.banner.visible, "Player one's pad leaves again") + press(2, JOY_BUTTON_A) + await frames(2) + check(not paused and not service.banner.visible, "Another pad can press Resume") + press(2, JOY_BUTTON_DPAD_LEFT) + check(service.family == "nintendo", "and becomes player one on its next press") + await frames(5) + + +func runner_pauses_and_resumes() -> void: + change_scene_to_file("res://scenes/runner/runner_game.tscn") + await frames(5) + var game := current_scene + game.start_run(7) + await frames(5) + fresh_pads() + await plug(0, SWITCH_PRO) + await unplug(0) + check(game.paused and paused, "A disconnect pauses the runner") + await plug(1, SWITCH_PRO) + check(not game.paused and not paused, "Reconnecting resumes the runner") + await frames(5) + + +func menu_disconnect_frees_player_one() -> void: + change_scene_to_file("res://scenes/main_menu/main_menu.tscn") + await frames(10) + fresh_pads() + await plug(0, XBOX) + await plug(1, DUALSENSE) + await unplug(0) + check(not paused and not service.banner.visible, "A disconnect in a menu does not pause") + check(not service.roster.is_waiting(&"p1"), "Player one is free for the next pad") + press(1, JOY_BUTTON_DPAD_DOWN) + check(service.family == "playstation", "The next pad used becomes player one") diff --git a/hoppys-adventure-arcade/tests/controller_service.gd.uid b/hoppys-adventure-arcade/tests/controller_service.gd.uid new file mode 100644 index 00000000..63954feb --- /dev/null +++ b/hoppys-adventure-arcade/tests/controller_service.gd.uid @@ -0,0 +1 @@ +uid://bp8poyp71xgt diff --git a/hoppys-adventure-arcade/tests/heroes_villains_logic.gd b/hoppys-adventure-arcade/tests/heroes_villains_logic.gd index f7c55588..8c3248d8 100644 --- a/hoppys-adventure-arcade/tests/heroes_villains_logic.gd +++ b/hoppys-adventure-arcade/tests/heroes_villains_logic.gd @@ -353,7 +353,9 @@ func run() -> void: game.player.height=0 game.player.position=weapon_drop.node.position+Vector2(20,0) game._physics_process(1.0/60.0) - check(game.prompt.is_visible_in_tree() and game.prompt.text.begins_with("E / Y · PICK UP"),"Pickup prompt shows within 85 px after a stage change (got '%s')" % game.prompt.text) + # The prompt names the pickup key and, with a controller connected, its button. + var pickup: String = preload("res://app/controller_prompts.gd").either("E", &"brawl_pickup", true)+" · PICK UP" + check(game.prompt.is_visible_in_tree() and game.prompt.text.begins_with(pickup),"Pickup prompt shows within 85 px after a stage change (got '%s')" % game.prompt.text) var score_kept: int=game.score game.boss_defeated() check(game.prompt.visible,"boss_defeated clears the pickup line without hiding it (text '%s')" % game.prompt.text) diff --git a/hoppys-adventure-arcade/tests/support/fake_pads.gd b/hoppys-adventure-arcade/tests/support/fake_pads.gd new file mode 100644 index 00000000..349697dd --- /dev/null +++ b/hoppys-adventure-arcade/tests/support/fake_pads.gd @@ -0,0 +1,34 @@ +extends RefCounted +## A stand-in for the cr_kit roster's Hardware: pads are plugged and unplugged by the test. + +var _pads := {} # device -> {"name", "guid", "vendor"} +var _order: Array[int] = [] + + +func plug(device: int, pad_name: String, pad_guid: String = "", vendor: int = 0) -> void: + _pads[device] = {"name": pad_name, "guid": pad_guid if not pad_guid.is_empty() else "guid-" + pad_name, "vendor": vendor} + if not _order.has(device): + _order.append(device) + + +func unplug(device: int) -> void: + _pads.erase(device) + _order.erase(device) + + +func devices() -> Array[int]: + return _order.duplicate() + + +func name(device: int) -> String: + return _pads[device].name + + +func guid(device: int) -> String: + return _pads[device].guid + + +## SDL-backed platforms report the vendor id as a decimal string; keep that shape. +func info(device: int) -> Dictionary: + var vendor: int = _pads[device].vendor + return {"vendor_id": str(vendor)} if vendor != 0 else {} diff --git a/hoppys-adventure-arcade/tests/support/fake_pads.gd.uid b/hoppys-adventure-arcade/tests/support/fake_pads.gd.uid new file mode 100644 index 00000000..42952a74 --- /dev/null +++ b/hoppys-adventure-arcade/tests/support/fake_pads.gd.uid @@ -0,0 +1 @@ +uid://0cdn4du6npmg From 29661e7807e2bbfeb32a333beefebec443b607d4 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Fri, 9 Oct 2026 22:37:54 -0700 Subject: [PATCH 2/2] fix(input): drop stick and D-pad wording without a pad; isolate the suite's leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses CodeRabbit's review of #69: - Prompts.if_pad() returns its text only while a controller is connected, so "WASD / stick", "Arrows / D-pad / stick" and "↑ ↓ / D-PAD" read "WASD", "Arrows" and "↑ ↓" on keyboard alone, like the action prompts. - tests/controller_service.gd instantiates the runner, points it at a throwaway leaderboard before it enters the tree, and deletes that file and its .tmp and .bak siblings at the end, as runner_smoke does. --- hoppys-adventure-arcade/app/controller_prompts.gd | 6 ++++++ hoppys-adventure-arcade/modes/beatem_up/game.gd | 3 ++- .../modes/beatem_up/reinvent/game.gd | 3 ++- .../scenes/beatem_up/beatem_menu.gd | 4 ++-- .../scenes/main_menu/main_menu.gd | 3 ++- .../scenes/runner/runner_menu.gd | 3 ++- .../tests/controller_service.gd | 15 +++++++++++++-- 7 files changed, 29 insertions(+), 8 deletions(-) diff --git a/hoppys-adventure-arcade/app/controller_prompts.gd b/hoppys-adventure-arcade/app/controller_prompts.gd index 159a3c8e..1b1f7669 100644 --- a/hoppys-adventure-arcade/app/controller_prompts.gd +++ b/hoppys-adventure-arcade/app/controller_prompts.gd @@ -20,6 +20,12 @@ static func keys_or_pad(keys: String, pad_label: String) -> String: return keys if pad_label.is_empty() else "%s / %s" % [keys, pad_label] +## `text` while a controller is connected, "" otherwise: `keys_or_pad("WASD", if_pad("stick"))`. +static func if_pad(text: String) -> String: + var controllers := service() + return text if controllers and not controllers.family.is_empty() else "" + + ## keys_or_pad() for an action: `either("Space", &"jump")`. static func either(keys: String, action: StringName, upper: bool = false) -> String: var label := pad(action) diff --git a/hoppys-adventure-arcade/modes/beatem_up/game.gd b/hoppys-adventure-arcade/modes/beatem_up/game.gd index 99e92925..0bfd2274 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/game.gd @@ -697,7 +697,8 @@ func _build_ui() -> void: ui.add_child(prompt) var controls := Style.label("",13) Prompts.track(controls, func() -> String: - return "WASD / stick · Move %s · Jump %s · Combo %s · Pickup %s · Pause" % [ + return "%s · Move %s · Jump %s · Combo %s · Pickup %s · Pause" % [ + Prompts.keys_or_pad("WASD", Prompts.if_pad("stick")), Prompts.either("Space", &"brawl_jump"), Prompts.either("J, X", &"brawl_attack"), Prompts.either("E", &"brawl_pickup"), diff --git a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd index 4f8051df..2cc42cb8 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd @@ -685,7 +685,8 @@ func _build_ui() -> void: ui.add_child(prompt) var controls := Style.label("",13) Prompts.track(controls, func() -> String: - return "WASD / stick · Move %s · Jump %s · Combo %s · Pickup Hold %s · REVIEW %s · Pause" % [ + return "%s · Move %s · Jump %s · Combo %s · Pickup Hold %s · REVIEW %s · Pause" % [ + Prompts.keys_or_pad("WASD", Prompts.if_pad("stick")), Prompts.either("Space", &"brawl_jump"), Prompts.either("J, X", &"brawl_attack"), Prompts.either("E", &"brawl_pickup"), diff --git a/hoppys-adventure-arcade/scenes/beatem_up/beatem_menu.gd b/hoppys-adventure-arcade/scenes/beatem_up/beatem_menu.gd index 5e553f6d..a8d3355c 100644 --- a/hoppys-adventure-arcade/scenes/beatem_up/beatem_menu.gd +++ b/hoppys-adventure-arcade/scenes/beatem_up/beatem_menu.gd @@ -60,8 +60,8 @@ func _ready() -> void: quit.pressed.connect(func(): AppRouter.quit_game()) var controls := Style.label("",14) Prompts.track(controls, func() -> String: - return "WASD / stick: move · %s: jump · %s: combo\n%s: pick up weapon · %s: pause\nHeroes: hold %s to charge REVIEW after unlocking it" % [ - Prompts.either("Space", &"brawl_jump"), Prompts.either("J or X", &"brawl_attack"), + return "%s: move · %s: jump · %s: combo\n%s: pick up weapon · %s: pause\nHeroes: hold %s to charge REVIEW after unlocking it" % [ + Prompts.keys_or_pad("WASD", Prompts.if_pad("stick")), Prompts.either("Space", &"brawl_jump"), Prompts.either("J or X", &"brawl_attack"), Prompts.either("E", &"brawl_pickup"), Prompts.either("Esc", &"pause"), Prompts.either("Q", &"review_burst")]) column.add_child(controls) caption=Style.label("",16,Color("ffb366")) diff --git a/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd b/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd index d70326a3..201d91e0 100644 --- a/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd +++ b/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd @@ -260,7 +260,8 @@ func _open_settings(invoker: Control) -> void: SaveService.apply_display()) var hint := Style.label("", 13, Color("afbdc9")) Prompts.track(hint, func() -> String: - return "Arrows / D-pad / stick: select\n%s: confirm • %s: back" % [ + return "%s: select\n%s: confirm • %s: back" % [ + Prompts.keys_or_pad("Arrows", Prompts.if_pad("D-pad / stick")), Prompts.either("Enter", &"ui_accept"), Prompts.either("Esc", &"ui_cancel")]) column.add_child(hint) diff --git a/hoppys-adventure-arcade/scenes/runner/runner_menu.gd b/hoppys-adventure-arcade/scenes/runner/runner_menu.gd index 1a31db0f..f738f6ea 100644 --- a/hoppys-adventure-arcade/scenes/runner/runner_menu.gd +++ b/hoppys-adventure-arcade/scenes/runner/runner_menu.gd @@ -86,7 +86,8 @@ func _ready() -> void: controls.add_child(Style.teaching_legend(true)) controls.add_child(Style.label("Two hits without recovery\nand the backlog catches you.", 18)) Prompts.track(_label("", 12, Vector2(76, 670), Color("9baebc")), func() -> String: - return "↑ ↓ / D-PAD SELECT %s CONFIRM %s BACK" % [ + return "%s SELECT %s CONFIRM %s BACK" % [ + Prompts.keys_or_pad("↑ ↓", Prompts.if_pad("D-PAD")), Prompts.either("ENTER", &"ui_accept", true), Prompts.either("ESC", &"ui_cancel", true)]) buttons[0].grab_focus() diff --git a/hoppys-adventure-arcade/tests/controller_service.gd b/hoppys-adventure-arcade/tests/controller_service.gd index 2c301e07..a5576668 100644 --- a/hoppys-adventure-arcade/tests/controller_service.gd +++ b/hoppys-adventure-arcade/tests/controller_service.gd @@ -9,6 +9,7 @@ const XBOX := ["Xbox Series X Controller", "xbox-guid", 0x045e] const DUALSENSE := ["DualSense Wireless Controller", "sony-guid", 0x054c] const SWITCH_PRO := ["Nintendo Switch Pro Controller", "nintendo-guid", 0x057e] var failures := 0 +var leaderboard_path := "user://controller_service_leaderboard_%d.cfg" % (randi() % 1000000) var service: Node var pads: RefCounted @@ -67,6 +68,9 @@ func run() -> void: await runner_pauses_and_resumes() await menu_disconnect_frees_player_one() print("CONTROLLER SERVICE: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures)") + for suffix in ["", ".tmp", ".bak"]: + if FileAccess.file_exists(leaderboard_path + suffix): + DirAccess.remove_absolute(leaderboard_path + suffix) current_scene.queue_free() await frames(5) var audio := root.get_node("AudioService") @@ -118,6 +122,7 @@ func prompts_follow_the_pad_in_use() -> void: service.track(label, func() -> String: return Prompts.either("Space", &"jump")) await plug(0, XBOX) check(service.family == "xbox" and label.text == "Space / A", "Xbox prompt, got %s" % label.text) + check(Prompts.keys_or_pad("WASD", Prompts.if_pad("stick")) == "WASD / stick", "Stick wording with a pad") await plug(1, DUALSENSE) check(service.family == "xbox", "Connecting a second pad does not switch the prompts") press(1, JOY_BUTTON_X) @@ -132,6 +137,7 @@ func prompts_follow_the_pad_in_use() -> void: await unplug(1) await unplug(0) check(service.family == "" and label.text == "Space", "Prompts drop pad labels with no pad left") + check(Prompts.keys_or_pad("WASD", Prompts.if_pad("stick")) == "WASD", "No stick wording without a pad") label.free() check(service.family_changed.get_connections().is_empty(), "A freed label stops tracking") @@ -191,9 +197,14 @@ func brawler_hands_over_to_another_pad() -> void: func runner_pauses_and_resumes() -> void: - change_scene_to_file("res://scenes/runner/runner_game.tscn") + # Point the run at a throwaway leaderboard before it enters the tree; run() deletes it. + var game := (load("res://scenes/runner/runner_game.tscn") as PackedScene).instantiate() + game.leaderboard_path = leaderboard_path + var previous := current_scene + root.add_child(game) + current_scene = game + previous.queue_free() await frames(5) - var game := current_scene game.start_run(7) await frames(5) fresh_pads()