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..1b1f7669 --- /dev/null +++ b/hoppys-adventure-arcade/app/controller_prompts.gd @@ -0,0 +1,41 @@ +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] + + +## `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) + 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..0bfd2274 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,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 Esc · Pause",13) + var controls := Style.label("",13) + Prompts.track(controls, func() -> String: + 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"), + 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 +859,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..2cc42cb8 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,15 @@ 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 "%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"), + 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 +754,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 +837,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..a8d3355c 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 "%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")) 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..201d91e0 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,13 @@ 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 "%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) 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..f738f6ea 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,23 @@ 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 "%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() _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 +104,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..a5576668 --- /dev/null +++ b/hoppys-adventure-arcade/tests/controller_service.gd @@ -0,0 +1,229 @@ +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 leaderboard_path := "user://controller_service_leaderboard_%d.cfg" % (randi() % 1000000) +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)") + 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") + 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) + 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) + 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") + 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") + + +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: + # 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) + 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