Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ jobs:
code_city_logic
code_city_motion
code_city_smoke
controller_service
stage_flow
beatemup_knockout
beatemup_story
Expand Down
41 changes: 41 additions & 0 deletions hoppys-adventure-arcade/app/controller_prompts.gd
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions hoppys-adventure-arcade/app/controller_prompts.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://dhqxfnbifn5ja
203 changes: 203 additions & 0 deletions hoppys-adventure-arcade/app/controller_service.gd
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions hoppys-adventure-arcade/app/controller_service.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://qm67od4v1o33
8 changes: 3 additions & 5 deletions hoppys-adventure-arcade/modes/beatem_up/catalog.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions hoppys-adventure-arcade/modes/beatem_up/game.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
17 changes: 13 additions & 4 deletions hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions hoppys-adventure-arcade/project.godot
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
8 changes: 7 additions & 1 deletion hoppys-adventure-arcade/scenes/beatem_up/beatem_menu.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading