diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4245a21..c4e5e2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest @@ -12,6 +19,12 @@ jobs: - name: Check out the repository uses: actions/checkout@v4 + - name: Cache the GUT addon + uses: actions/cache@v4 + with: + path: addons/gut + key: gut-${{ hashFiles('tools/gut.version.json') }} + - name: Install Godot 4.6 uses: chickensoft-games/setup-godot@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ec17257 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this project are listed here. +The format follows Keep a Changelog. +This project uses semantic versioning. + +## [0.2.0] - 2026-08-03 + +Added + +- Full gamepad support for every action. +- Analog stick movement with a deadzone. +- Normalised diagonal movement speed. +- D-pad and stick bindings for all movement. +- Gamepad bindings for attack, pause, and new run. +- Control hints that match the active device. +- Unit tests for the input bindings and helpers. + +## [0.1.0] - 2026-08-03 + +Added + +- Seed-driven dungeon generation with three biomes. +- Rooms, corridors, locked doors, and keys. +- Monsters, melee combat, and drop tables. +- Deterministic replay from a seed string. +- Procedural tile art and a minimap. +- A headless test suite and a smoke test. diff --git a/README.md b/README.md index c0b321a..9c733e9 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ The same seed always builds the same dungeon. - Procedural pixel art with no bundled image files. - A minimap, a health bar, and run summary overlays. - Deterministic generation for replayable runs. +- Full gamepad support with analog movement. ## First release @@ -37,6 +38,14 @@ The generator guarantees the exit is always reachable. You can walk, fight, collect loot, and finish a run. You can replay any run from its seed. +## This release + +This release adds full gamepad support. +Every action has a gamepad binding. +Movement uses the left stick or the d-pad. +Analog input gets a deadzone and a diagonal speed cap. +Menus show the controls for the active device. + ## Requirements - Godot 4.6 or newer. @@ -63,6 +72,13 @@ Press N for a new run. Press M to toggle the minimap. Press Escape to pause. +A gamepad works too. +Move with the left stick or the d-pad. +Attack with A or the right shoulder button. +Press Y for a new run. +Press Select to toggle the minimap. +Press Start to pause. + ## Run the tests Run `tools/run_tests.ps1` on Windows. @@ -74,9 +90,9 @@ Tests run headless, so no window opens. ## Install the test framework GUT is a test addon for Godot. -The tool scripts download and install it. +The tool scripts can download and install it. The version and checksum are pinned in `tools/gut.version.json`. -The addon is not committed to the repository. +The tests run from the copy in `addons/gut`. ## How it works @@ -94,6 +110,7 @@ The scene controller turns the map into a live game. - `scripts/dungeon` holds the generator and map logic. - `scripts/combat` holds stats, monsters, and loot tables. +- `scripts/input` holds the controls helper. - `scripts/world` renders tiles and builds the minimap. - `scripts/actors` holds the hero, monsters, and pickups. - `scripts/ui` builds the HUD and overlays. @@ -110,18 +127,22 @@ Monsters never cross a locked door. ## Evaluation evidence -The suite has 56 tests. -It covers generation, biomes, combat, drops, and pathfinding. -All 56 tests pass in a headless run. +The suite has 68 tests. +It covers generation, biomes, combat, drops, pathfinding, and input. +All 68 tests pass in a headless run. A smoke test loads the game and spawns a fixed-seed run. +The smoke test also checks every action has a gamepad binding. ## Roadmap -- Add multi-floor descent and a depth counter. -- Add ranged monsters and projectiles. -- Add sound and music. -- Add more biomes and items. -- Add controller support. +Done in this release: +- Full gamepad support. + +Next up: +- Multi-floor descent and a depth counter. +- Ranged monsters and projectiles. +- Sound and music. +- More biomes and items. ## Limitations diff --git a/docs/architecture.md b/docs/architecture.md index 1b7dde2..df3db0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,19 @@ The world renders from a tile map. A `TileArt` class draws every sprite from pixel patterns. The biome palette recolors the tiles at run time. +## Input handling + +A `Controls` class reads all movement input. +It combines the keyboard and the first connected gamepad. +It applies a deadzone so a resting stick stays still. +It caps diagonal speed at one. +The hero reads its movement from this class. + +Every action has a keyboard and a gamepad binding. +The input map in `project.godot` holds both sets. +Menus ask `Controls` for the correct labels. +The hints update when a gamepad connects or disconnects. + ## Testing The suite runs headless with GUT. diff --git a/project.godot b/project.godot index 343882a..0afbba8 100644 --- a/project.godot +++ b/project.godot @@ -26,27 +26,35 @@ window/stretch/aspect="keep" [input] move_up={ -"deadzone": 0.4, +"deadzone": 0.25, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null) ] } move_down={ -"deadzone": 0.4, +"deadzone": 0.25, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":12,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null) ] } move_left={ -"deadzone": 0.4, +"deadzone": 0.25, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":13,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null) ] } move_right={ -"deadzone": 0.4, +"deadzone": 0.25, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":14,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null) ] } attack={ @@ -54,21 +62,26 @@ attack={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) , Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":9,"pressure":0.0,"pressed":true,"script":null) ] } new_run={ "deadzone": 0.4, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":78,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":true,"script":null) ] } toggle_minimap={ "deadzone": 0.4, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":77,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":true,"script":null) ] } pause={ "deadzone": 0.4, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":7,"pressure":0.0,"pressed":true,"script":null) ] } diff --git a/scripts/actors/player.gd b/scripts/actors/player.gd index 1ee672f..5fa8967 100644 --- a/scripts/actors/player.gd +++ b/scripts/actors/player.gd @@ -70,7 +70,7 @@ func _physics_process(p_delta: float) -> void: _handle_attack() func _wait_for_input() -> void: - var input := Input.get_vector("move_left", "move_right", "move_up", "move_down") + var input := Controls.movement_vector() if input == Vector2.ZERO: return facing = _dominant_axis(input) diff --git a/scripts/input/controls.gd b/scripts/input/controls.gd new file mode 100644 index 0000000..6f93cab --- /dev/null +++ b/scripts/input/controls.gd @@ -0,0 +1,66 @@ +class_name Controls +extends RefCounted +## Input helpers for the keyboard and a gamepad. +## +## The game reads movement through this class. It applies a deadzone, +## caps diagonal speed, and picks display labels for the active device. +## The helpers are pure functions so the test suite can assert them. + +const MOVE_DEADZONE := 0.25 + +## Every action the game binds. Tests and the smoke test use this list +## to prove the input map stays complete. +const ACTIONS: Array[StringName] = [ + &"move_left", &"move_right", &"move_up", &"move_down", + &"attack", &"new_run", &"toggle_minimap", &"pause", +] + +## Movement from the keyboard or the first connected gamepad. +## Returns a vector with a length of at most one. +static func movement_vector() -> Vector2: + return sanitize_axis( + Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down", MOVE_DEADZONE) + ) + +## Zeroes inputs below the deadzone and caps the length at one. +static func sanitize_axis(p_vector: Vector2, p_deadzone: float = MOVE_DEADZONE) -> Vector2: + if p_vector.length() < p_deadzone: + return Vector2.ZERO + return clamp_unit(p_vector) + +## Caps the vector length while keeping its direction. +static func clamp_unit(p_vector: Vector2, p_max_length: float = 1.0) -> Vector2: + return p_vector.limit_length(p_max_length) + +## True when a gamepad is connected. +static func gamepad_active() -> bool: + return not Input.get_connected_joypads().is_empty() + +## Returns the display label for an action on the active device. +static func action_label(p_action: StringName) -> String: + return label_for(p_action, gamepad_active()) + +## Returns the display label for an action on a given device. +static func label_for(p_action: StringName, p_gamepad: bool) -> String: + match p_action: + &"move": + return "Left stick or d-pad" if p_gamepad else "WASD or arrows" + &"attack": + return "A or R shoulder" if p_gamepad else "Space, J, or click" + &"new_run": + return "Y" if p_gamepad else "N" + &"toggle_minimap": + return "Select" if p_gamepad else "M" + &"pause": + return "Start" if p_gamepad else "Esc" + return "" + +## A two-line hint for the menu, tailored to the active device. +static func hint_text() -> String: + return hint_for(gamepad_active()) + +## A two-line hint for the menu, tailored to a given device. +static func hint_for(p_gamepad: bool) -> String: + if p_gamepad: + return "Move: left stick or d-pad Attack: A or R shoulder\nOpen doors: walk in with a key New run: Y Pause: Start" + return "Move: WASD or arrows Attack: Space, J, or click\nOpen doors: walk in with a key New run: N Pause: Esc" diff --git a/scripts/input/controls.gd.uid b/scripts/input/controls.gd.uid new file mode 100644 index 0000000..4c17e7a --- /dev/null +++ b/scripts/input/controls.gd.uid @@ -0,0 +1 @@ +uid://43vwr76mihtr diff --git a/scripts/ui/menu_overlay.gd b/scripts/ui/menu_overlay.gd index e8f68f3..0e6e670 100644 --- a/scripts/ui/menu_overlay.gd +++ b/scripts/ui/menu_overlay.gd @@ -11,11 +11,13 @@ signal continue_requested var _seed_edit: LineEdit = null var _continue_button: Button = null var _error_label: Label = null +var _hint_label: Label = null func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS visible = false _build() + Input.joy_connection_changed.connect(_on_joy_connection_changed) func show_menu(p_can_continue: bool) -> void: visible = true @@ -36,6 +38,11 @@ func _unhandled_input(p_event: InputEvent) -> void: continue_requested.emit() get_viewport().set_input_as_handled() +## Refreshes the control hints when a gamepad connects or disconnects. +func _on_joy_connection_changed(_p_device: int, _p_connected: bool) -> void: + if _hint_label != null: + _hint_label.text = Controls.hint_text() + func _build() -> void: var dim := ColorRect.new() dim.color = Color(0, 0, 0, 0.75) @@ -83,11 +90,11 @@ func _build() -> void: _continue_button.text = "Continue" _continue_button.custom_minimum_size = Vector2(0, 36) - var hint := Label.new() - hint.text = "Move: WASD or arrows Attack: Space, J, or click\nOpen doors: walk in with a key New run: N Pause: Escape" - hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - hint.add_theme_font_size_override("font_size", 13) + _hint_label = Label.new() + _hint_label.text = Controls.hint_text() + _hint_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _hint_label.add_theme_font_size_override("font_size", 13) box.add_child(title) box.add_child(subtitle) @@ -95,7 +102,7 @@ func _build() -> void: box.add_child(_error_label) box.add_child(start_button) box.add_child(_continue_button) - box.add_child(hint) + box.add_child(_hint_label) margin.add_child(box) panel.add_child(margin) diff --git a/scripts/ui/result_overlay.gd b/scripts/ui/result_overlay.gd index 0f5b07e..b6417e5 100644 --- a/scripts/ui/result_overlay.gd +++ b/scripts/ui/result_overlay.gd @@ -9,6 +9,7 @@ signal new_run_requested(same_seed: bool) var _title: Label = null var _summary: Label = null +var _new_button: Button = null var _same_seed_button: Button = null func _ready() -> void: @@ -26,6 +27,7 @@ func show_result(p_won: bool, p_seed: String, p_depth: int, p_coins: int, p_time p_seed, p_depth, p_coins, minutes, seconds, ] _same_seed_button.visible = p_won + _new_button.grab_focus() func hide_result() -> void: visible = false @@ -63,6 +65,7 @@ func _build() -> void: _same_seed_button = Button.new() _same_seed_button.text = "Replay this seed" _same_seed_button.custom_minimum_size = Vector2(0, 36) + _new_button = new_button box.add_child(_title) box.add_child(_summary) diff --git a/tests/unit/test_controls.gd b/tests/unit/test_controls.gd new file mode 100644 index 0000000..6f5878c --- /dev/null +++ b/tests/unit/test_controls.gd @@ -0,0 +1,92 @@ +extends GutTest +## Input bindings and helper math for controller support. + +const ACTIONS: Array[StringName] = [ + &"move_left", &"move_right", &"move_up", &"move_down", + &"attack", &"new_run", &"toggle_minimap", &"pause", +] + +const LABEL_KEYS: Array[StringName] = [ + &"move", &"attack", &"new_run", &"toggle_minimap", &"pause", +] + +func test_every_action_has_a_keyboard_binding() -> void: + for action in ACTIONS: + assert_true(_has_key(action), "%s has no keyboard binding" % action) + +func test_every_action_has_a_gamepad_binding() -> void: + for action in ACTIONS: + assert_true(_has_joypad(action), "%s has no gamepad binding" % action) + +func test_movement_bindings_cover_stick_and_dpad() -> void: + assert_true(_has_stick_axis(&"move_up", 1, -1.0)) + assert_true(_has_stick_axis(&"move_down", 1, 1.0)) + assert_true(_has_stick_axis(&"move_left", 0, -1.0)) + assert_true(_has_stick_axis(&"move_right", 0, 1.0)) + assert_true(_has_button(&"move_up", 11)) + assert_true(_has_button(&"move_down", 12)) + assert_true(_has_button(&"move_left", 13)) + assert_true(_has_button(&"move_right", 14)) + +func test_action_buttons_are_bound() -> void: + assert_true(_has_button(&"attack", 0)) + assert_true(_has_button(&"new_run", 3)) + assert_true(_has_button(&"toggle_minimap", 6)) + assert_true(_has_button(&"pause", 7)) + +func test_deadzone_filters_small_input() -> void: + assert_eq(Controls.sanitize_axis(Vector2(0.1, 0.0)), Vector2.ZERO) + assert_ne(Controls.sanitize_axis(Vector2(0.5, 0.0)), Vector2.ZERO) + +func test_diagonal_speed_is_capped() -> void: + var result := Controls.clamp_unit(Vector2(1.0, 1.0)) + assert_lte(result.length(), 1.0001) + +func test_short_vectors_are_not_scaled() -> void: + assert_eq(Controls.clamp_unit(Vector2(0.3, 0.4)), Vector2(0.3, 0.4)) + +func test_sanitize_keeps_units_at_most_one() -> void: + var result := Controls.sanitize_axis(Vector2(0.9, 0.9)) + assert_lte(result.length(), 1.0001) + +func test_headless_movement_is_still() -> void: + assert_eq(Controls.movement_vector(), Vector2.ZERO) + +func test_action_labels_exist_for_both_devices() -> void: + for label_key in LABEL_KEYS: + assert_ne(Controls.label_for(label_key, false), "", "%s label missing" % label_key) + assert_ne(Controls.label_for(label_key, true), "", "%s gamepad label missing" % label_key) + +func test_device_labels_differ() -> void: + assert_ne(Controls.label_for(&"move", false), Controls.label_for(&"move", true)) + assert_ne(Controls.label_for(&"attack", false), Controls.label_for(&"attack", true)) + +func test_hint_text_is_not_empty() -> void: + assert_ne(Controls.hint_for(false), "") + assert_ne(Controls.hint_for(true), "") + +func _has_key(p_action: StringName) -> bool: + for event in InputMap.action_get_events(p_action): + if event is InputEventKey: + return true + return false + +func _has_joypad(p_action: StringName) -> bool: + for event in InputMap.action_get_events(p_action): + if event is InputEventJoypadButton or event is InputEventJoypadMotion: + return true + return false + +func _has_button(p_action: StringName, p_index: int) -> bool: + for event in InputMap.action_get_events(p_action): + if event is InputEventJoypadButton and event.button_index == p_index: + return true + return false + +func _has_stick_axis(p_action: StringName, p_axis: int, p_value: float) -> bool: + for event in InputMap.action_get_events(p_action): + if event is InputEventJoypadMotion \ + and event.axis == p_axis \ + and is_equal_approx(event.axis_value, p_value): + return true + return false diff --git a/tests/unit/test_controls.gd.uid b/tests/unit/test_controls.gd.uid new file mode 100644 index 0000000..22aa159 --- /dev/null +++ b/tests/unit/test_controls.gd.uid @@ -0,0 +1 @@ +uid://se65jb6ls0ha diff --git a/tools/smoke.gd b/tools/smoke.gd index dc1d06d..3b50254 100644 --- a/tools/smoke.gd +++ b/tools/smoke.gd @@ -47,6 +47,19 @@ func _verify() -> void: _fail("exit is not reachable from the start") elif not _main.run.solvable: _fail("dungeon is not solvable") + _verify_input_bindings() + +func _verify_input_bindings() -> void: + for action in Controls.ACTIONS: + var has_joypad := false + for event in InputMap.action_get_events(action): + if event is InputEventJoypadButton or event is InputEventJoypadMotion: + has_joypad = true + break + if not has_joypad: + _fail("action %s has no gamepad binding" % action) + if Controls.hint_for(false).is_empty() or Controls.hint_for(true).is_empty(): + _fail("control hints are empty") if _failed: print("[smoke] FAILED")