Skip to content
Closed
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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ jobs:
- name: Import project resources
run: godot --headless --import

- name: Check the working tree stays clean
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "The import generated files that are not committed:"
git status --porcelain
exit 1
fi

- name: Run the GUT test suite
run: godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to this project are listed here.
The format follows Keep a Changelog.
This project uses semantic versioning.

## [0.3.0] - 2026-08-03

Added

- Multi-floor descent through three floors per run.
- A floor counter in the HUD and the run summary.
- Stairs-down tiles on every floor below the deepest.
- Per-floor difficulty scaling for monsters and hero stats.
- Deterministic floor seeds derived from the run seed.
- A run profile module that drives the descent rules.
- Unit and integration tests for floors and replay.

Fixed

- The hero no longer stacks sprites when a run restarts.

## [0.2.0] - 2026-08-03

Added
Expand Down
37 changes: 37 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Contributing

Thank you for improving Dungeonwright.
This guide explains how to set up, change, and test the project.

## Development setup

1. Install Godot 4.6 from the official site.
2. Clone this repository.
3. Set `GODOT_BIN` to the Godot executable.
4. Install the pinned GUT addon with `tools/install_gut.sh` or `tools/install_gut.ps1`.

## Make a change

Keep the generation code pure data.
Add deterministic tests for every behaviour you change.
Run the test suite before you open a pull request.

## Run the checks

1. Run `tools/run_tests.sh` on Linux or macOS.
2. Run `tools/run_tests.ps1` on Windows.
3. Run `tools/smoke.gd` headless with Godot.

The suite must pass with a clean import.

## Code style

Use tabs for indentation in GDScript files.
Follow the existing naming and comment style.
Write public documentation with ASD-STE100 rules.

## Commit messages

Write a short subject line that states the change.
Add a body that explains why the change is needed.
Reference an issue number when one exists.
88 changes: 66 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Dungeonwright

A seeded dungeon crawler built with Godot and GDScript.
Every run builds a new dungeon that you can explore and finish.
Every run builds a three-floor dungeon that you can explore and finish.

```
####.D.############
Expand All @@ -17,34 +17,72 @@ Every run builds a new dungeon that you can explore and finish.

Dungeonwright generates a connected dungeon on every run.
You explore rooms and corridors.
You find keys, open locked doors, and reach the exit.
You find keys, open locked doors, and descend stairs.
A run spans three floors, and the same seed replays every floor.
The same seed always builds the same dungeon.

## Features

- A new map for every run, driven by a seed.
- A new three-floor dungeon for every run, driven by a seed.
- Rooms, corridors, locked doors, and keys.
- Stairs down and a floor counter in the HUD.
- Three biomes with different generation rules.
- Deeper floors add more monsters and tougher hero stats.
- Monsters with simple combat and balanced drops.
- 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
## This release

This release ships a playable demo.
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 adds multi-floor descent.
Reaching the exit on a floor brings you to the stairs.
The stairs carry you to the next, harder floor.
A floor counter shows your depth in the top-left panel.
The deepest floor holds the true exit, where the run ends in victory.

## This release
## Sample run

The generator prints a dungeon as text in the terminal.
Here is floor two of a run, seed `0093CI`.
`#` is a wall, `.` is a floor, `D` is a locked door, and `>` is the stairs down.

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.
```
##############################################
##############################################
##############......##########################
##############......##########################
##############......##########################
##############......##########################
##############......##########################
##############......###########.......########
####........#######D###########.......########
####........#######.###########.......########
####........###.........#######...>...########
####........###.........#######.......########
####........###.........#######.......########
####........###.........##########.###########
########.######....S.......D..####.###########
########.######.........#####.####.........###
########.######.........##.......#.........###
########.######.........##.......#.........###
#####.........#.........##.......#.........###
#####.........#####.######........D........###
#####.........#####.######.......##........###
#####.........#........###.......##........###
#####.........#........###.......###.#########
#####.........#........###########.....#######
#########..............###########.....#######
###############........###########.....#######
###############........###########.....#######
###############........###########.....#######
##############################################
##############################################
```

Floor one starts at `S`, and every floor stays solvable.
Replaying the seed `0093CI` rebuilds the same three floors.

## Requirements

Expand Down Expand Up @@ -106,8 +144,14 @@ The generation code is pure data.
It has no scene nodes, so tests run fast and deterministic.
The scene controller turns the map into a live game.

A run profile drives the whole descent.
It derives a fresh seed for each floor from the run seed.
It scales the biome rules so deeper floors get harder.
Reaching the exit on a shallow floor replaces it with stairs.

## Project layout

- `scripts/core` holds the run profile and shared run state.
- `scripts/dungeon` holds the generator and map logic.
- `scripts/combat` holds stats, monsters, and loot tables.
- `scripts/input` holds the controls helper.
Expand All @@ -120,34 +164,34 @@ The scene controller turns the map into a live game.

## Design guarantees

A seed always produces the same map.
A seed always produces the same three floors.
Doors never block the exit permanently.
Every key sits on the reachable side of its door.
Monsters never cross a locked door.
Deeper floors always stay solvable.

## Evaluation evidence

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 suite has 83 tests.
It covers generation, biomes, floors, combat, drops, pathfinding, and input.
All 83 tests pass in a headless run.
A smoke test loads the game and descends a fixed-seed run to the bottom.
The smoke test also checks every action has a gamepad binding.

## Roadmap

Done in this release:
- Full gamepad support.
- Multi-floor descent and a depth counter.

Next up:
- Multi-floor descent and a depth counter.
- Ranged monsters and projectiles.
- Sound and music.
- More biomes and items.

## Limitations

The demo has three biomes.
Each run is a single floor.
Each run is three floors deep.
All monsters use melee attacks.
The game has no audio yet.

Expand Down
10 changes: 10 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Security

Report security issues privately.
Do not open a public issue for a vulnerability.
Use the GitHub security tab to contact the maintainers.
Include a minimal reproduction when you can.

This project has no secrets or credentials.
The seed field accepts user input, but it only changes map generation.
The seed parser rejects invalid characters and length.
27 changes: 25 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ The class wraps the mulberry32 algorithm.
The output depends only on the seed, never on the platform.
Seeds display as six-character base-36 strings.

## Run profile and floors

A run spans three floors.
The `RunProfile` class owns this structure.
It derives a fresh seed for each floor from the run seed.
It scales a copy of the biome so deeper floors get harder.

Each floor uses the scaled biome config.
The generator stays floor-agnostic and always carves the base rules.
The scene controller converts the exit tile into stairs on shallow floors.
The deepest floor keeps the exit tile, so the run ends in victory.

The scaling is pure math.
It raises monster density, monster count, hero health, and hero damage.
Every scaled config stays inside the biome validation rules.
Replaying a run seed rebuilds the same floor sequence.

## Combat model

The player and each monster carry a `CombatStats` block.
Expand All @@ -91,6 +108,11 @@ The hero, monsters, and pickups are plain nodes.
The hero moves tile to tile with smooth interpolation.
Monsters follow short flood-fill paths.

Reaching the exit on a shallow floor starts the next floor.
The controller keeps the loot and rebuilds the hero stats.
It clears the world and reuses the same run seed.
The HUD floor counter and the run summary track the descent.

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.
Expand All @@ -111,9 +133,10 @@ The hints update when a gamepad connects or disconnects.
## Testing

The suite runs headless with GUT.
Unit tests cover the RNG, generator, biomes, combat, and drops.
Integration tests run many seeds across all biomes.
Unit tests cover the RNG, generator, biomes, floors, combat, and drops.
Integration tests run many seeds across all biomes and floors.
Every generated dungeon must be solvable.

Run the suite with `tools/run_tests`.
CI runs the same commands on every push.
A smoke test descends a fixed-seed run through every floor.
13 changes: 13 additions & 0 deletions scripts/actors/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func setup(
p_view: DungeonView,
p_occupancy: Dictionary
) -> void:
_clear_visuals()
stats = p_stats
grid_pos = p_start
view = p_view
Expand All @@ -58,6 +59,18 @@ func setup(
_add_light()
emit_hud()

## Removes the visuals from a previous setup so a floor change can
## rebuild the hero without stacking sprites.
func _clear_visuals() -> void:
for child in get_children():
child.queue_free()

## Resets the loot a hero carries into a brand-new run.
func reset_progress() -> void:
coins = 0
shards = 0
keys_held = 0

func _physics_process(p_delta: float) -> void:
if view == null or stats == null:
return
Expand Down
49 changes: 49 additions & 0 deletions scripts/core/run_profile.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
class_name RunProfile
extends RefCounted
## Defines the shape of a full run.
##
## A run spans several floors. Each floor is a fresh dungeon built from
## a seed derived from the run seed. The profile scales the biome rules
## so deeper floors carry more monsters and tougher hero stats. All the
## math is deterministic, so a run seed replays every floor exactly.

const TOTAL_FLOORS := 3

const FLOOR_SEED_TWEAK := 0x9E3779B1

## Extra monster density per floor beyond the first.
const MONSTER_DENSITY_STEP := 0.12
## Extra monster slots per floor beyond the first.
const MONSTER_CAP_STEP := 3
## Extra hero max health per floor beyond the first.
const HEALTH_STEP := 20
## Extra hero damage per floor beyond the first.
const DAMAGE_STEP := 2
## Extra doors per floor beyond the first.
const DOOR_STEP := 1
## Door count never rises above this bound.
const MAX_DOOR_COUNT := 5

## The number of floors in one run.
static func total_floors() -> int:
return TOTAL_FLOORS

## True when the given floor is the deepest floor of the run.
static func is_final_floor(p_floor: int) -> bool:
return p_floor >= TOTAL_FLOORS

## Returns the deterministic seed for one floor of a run.
static func floor_seed(p_run_seed: int, p_floor: int) -> int:
return (p_run_seed ^ (p_floor * FLOOR_SEED_TWEAK)) & SeededRng.SEED_MASK

## Returns a copy of the biome scaled for the given floor.
static func config_for(p_biome: DungeonConfig, p_floor: int) -> DungeonConfig:
var steps := maxi(p_floor, 1) - 1
var config := p_biome.clone()
config.monster_density = minf(0.9, config.monster_density + MONSTER_DENSITY_STEP * steps)
config.monster_cap += MONSTER_CAP_STEP * steps
config.starting_health += HEALTH_STEP * steps
config.player_damage += DAMAGE_STEP * steps
config.door_count_min = mini(config.door_count_min + DOOR_STEP * steps, MAX_DOOR_COUNT)
config.door_count_max = mini(config.door_count_max + DOOR_STEP * steps, MAX_DOOR_COUNT)
return config
1 change: 1 addition & 0 deletions scripts/core/run_profile.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://x2hwv0l3fmi0
1 change: 1 addition & 0 deletions scripts/core/run_state.gd
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ static var biome_id: StringName = &""
static var status: RunStatus = RunStatus.IDLE
static var started_at: float = 0.0
static var finished_at: float = 0.0
static var floor: int = 1

## Elapsed play time in seconds for the current run.
static func elapsed() -> float:
Expand Down
Loading
Loading