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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,26 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- 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:
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 31 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -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.
Expand All @@ -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

Expand Down
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 17 additions & 4 deletions project.godot
Original file line number Diff line number Diff line change
Expand Up @@ -26,49 +26,62 @@ 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={
"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":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)
]
}

Expand Down
2 changes: 1 addition & 1 deletion scripts/actors/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions scripts/input/controls.gd
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions scripts/input/controls.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://43vwr76mihtr
19 changes: 13 additions & 6 deletions scripts/ui/menu_overlay.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -83,19 +90,19 @@ 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)
box.add_child(_seed_edit)
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)
Expand Down
3 changes: 3 additions & 0 deletions scripts/ui/result_overlay.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading