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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ jobs:
path: addons/gut
key: gut-${{ hashFiles('tools/gut.version.json') }}

- name: Cache the Godot import cache
uses: actions/cache@v4
with:
path: .godot
key: godot-import-${{ hashFiles('project.godot', 'scenes/**', 'scripts/**') }}

- name: Install Godot 4.6
uses: chickensoft-games/setup-godot@v2
with:
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ 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 runs with a three-floor descent.
- A floor counter in the HUD and the run summary.
- A stairwell prompt between floors.
- Carried coins and health across floors.
- A small heal when the hero descends.
- Deterministic per-floor seeds derived from the run seed.
- Per-floor biome choice and monster scaling.
- RunProgression, a pure module for run-level rules.
- Unit and integration tests for the descent rules.

## [0.2.0] - 2026-08-03

Added
Expand Down
68 changes: 48 additions & 20 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 fresh dungeon, and every dungeon can repeat from its seed.

```
####.D.############
Expand All @@ -18,33 +18,30 @@ 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.
The same seed always builds the same dungeon.
A run now spans three floors.
Each floor is a new dungeon that gets harder as you descend.
The same seed always builds the same three-floor run.

## Features

- A new map for every run, driven by a seed.
- A new map for every floor, driven by a seed.
- Rooms, corridors, locked doors, and keys.
- A three-floor descent with a floor counter.
- Three biomes with different generation rules.
- 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 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

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.
This release adds the multi-floor descent.
A run now covers three floors.
Reaching the exit of a floor opens a stairwell.
The hero descends, keeps coins and health, and continues.
Monsters grow stronger on each deeper floor.
The final floor holds the goal.

## Requirements

Expand Down Expand Up @@ -79,6 +76,23 @@ Press Y for a new run.
Press Select to toggle the minimap.
Press Start to pause.

## Sample output

A seed like `A7KQ2M` produces one map per floor.
The map above shows a single floor.
The `D` marks a locked door.
The `E` marks the floor exit.
Deeper floors hold more monsters.

The test runner prints a summary when the suite finishes.

```
Tests 87
Passing Tests 87
Asserts 4193
---- All tests passed! ----
```

## Run the tests

Run `tools/run_tests.ps1` on Windows.
Expand All @@ -102,6 +116,11 @@ It picks the farthest room as the exit.
It places doors on corridors and puts each key on the safe side.
A solver then proves the dungeon can be completed.

A run is a descent through three floors.
`RunProgression` derives a seed and a biome for every floor.
The scene controller builds one floor at a time.
Progress carries over between floors.

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.
Expand All @@ -110,6 +129,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/core` holds the run state and the descent rules.
- `scripts/input` holds the controls helper.
- `scripts/world` renders tiles and builds the minimap.
- `scripts/actors` holds the hero, monsters, and pickups.
Expand All @@ -121,33 +141,41 @@ The scene controller turns the map into a live game.
## Design guarantees

A seed always produces the same map.
A run always replays the same floor sequence.
Doors never block the exit permanently.
Every key sits on the reachable side of its door.
Monsters never cross a locked door.

## Evaluation evidence

The suite has 68 tests.
It covers generation, biomes, combat, drops, pathfinding, and input.
All 68 tests pass in a headless run.
The suite has 87 tests.
It covers generation, biomes, combat, drops, pathfinding, input, and
the descent rules.
All 87 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.
The smoke test verifies a descent keeps coins and resets keys.

## Roadmap

Done in this release:
- Multi-floor descent and a floor counter.

Done in earlier releases:
- Seeded generation, rooms, doors, and keys.
- Combat, monsters, and loot tables.
- Procedural art, a minimap, and run overlays.
- Full gamepad support.

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.
A run has exactly three floors.
All monsters use melee attacks.
The game has no audio yet.

Expand Down
37 changes: 35 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Architecture

This document explains the design of Dungeonwright.
It covers the generation pipeline, the combat model, and the scene flow.
It covers the generation pipeline, the combat model, the multi-floor
run, and the scene flow.

## Generation pipeline

Expand All @@ -19,6 +20,10 @@ The pipeline runs in a fixed order.
6. Place doors and keys.
7. Scatter monsters in the rooms.

The optional floor number scales monster pressure.
Deeper floors hold more monsters.
Floor zero is neutral, so single-floor maps stay unchanged.

### Room placement

The generator draws random rectangles in the map bounds.
Expand Down Expand Up @@ -72,6 +77,29 @@ 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.

## Multi-floor runs

A run is a descent through three floors.
The `RunProgression` module holds the run-level rules.

Each floor gets its own map and biome.
The floor seed derives from the run seed and the floor number.
A fixed hash makes the sequence repeatable.

Every floor uses a floor-seeded biome choice.
Reaching the exit of a floor opens the stairwell prompt.
The hero chooses to descend or to stay.
On the final floor the exit is the goal and ends the run.

Progress carries across floors.
The hero keeps coins, health, and shards.
Keys reset because each floor has its own doors.
Descending restores a fixed amount of health.

Monsters scale with the floor.
Hit points and damage grow each floor.
The generator places more monsters on deeper floors.

## Combat model

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

`Main` builds one floor at a time.
It calls `RunProgression` for the biome and the floor seed.
Descending keeps the hero node and rebuilds the world.
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 +142,11 @@ 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.
Unit tests cover the RNG, generator, biomes, combat, drops, and the
run progression rules.
Integration tests run many seeds across all biomes.
Every generated dungeon must be solvable.
Multi-floor tests replay whole runs and check every floor stays solvable.

Run the suite with `tools/run_tests`.
CI runs the same commands on every push.
2 changes: 1 addition & 1 deletion project.godot
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ config_version=5
[application]

config/name="Dungeonwright"
config/description="A seeded dungeon crawler that builds a new, solvable map every run."
config/description="A seeded dungeon crawler that builds a new, solvable map for every floor of a three-floor descent."
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
Expand Down
12 changes: 11 additions & 1 deletion scenes/main.tscn
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
[gd_scene load_steps=7 format=3]
[gd_scene load_steps=8 format=3]

[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/world/dungeon_view.gd" id="2_view"]
[ext_resource type="PackedScene" path="res://scenes/actors/player.tscn" id="3_player"]
[ext_resource type="Script" path="res://scripts/ui/hud.gd" id="4_hud"]
[ext_resource type="Script" path="res://scripts/ui/menu_overlay.gd" id="5_menu"]
[ext_resource type="Script" path="res://scripts/ui/result_overlay.gd" id="6_result"]
[ext_resource type="Script" path="res://scripts/ui/descend_overlay.gd" id="7_descend"]

[node name="Main" type="Node2D"]
script = ExtResource("1_main")
Expand Down Expand Up @@ -50,6 +51,15 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("5_menu")

[node name="DescendOverlay" type="Control" parent="UI"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("7_descend")

[node name="ResultOverlay" type="Control" parent="UI"]
layout_mode = 3
anchors_preset = 15
Expand Down
10 changes: 6 additions & 4 deletions scripts/actors/monster_actor.gd
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ func setup(
p_start: Vector2i,
p_view: DungeonView,
p_occupancy: Dictionary,
p_target: Node2D
p_target: Node2D,
p_health_scale: float = 1.0,
p_damage_scale: float = 1.0
) -> void:
spec = p_spec
stats = CombatStats.make({
"max_health": p_spec.stats.max_health,
"health": p_spec.stats.max_health,
"damage": p_spec.stats.damage,
"max_health": maxi(1, roundi(p_spec.stats.max_health * p_health_scale)),
"health": maxi(1, roundi(p_spec.stats.max_health * p_health_scale)),
"damage": maxi(1, roundi(p_spec.stats.damage * p_damage_scale)),
"speed": p_spec.stats.speed,
"attack_range": p_spec.stats.attack_range,
"attack_cooldown": p_spec.stats.attack_cooldown,
Expand Down
32 changes: 25 additions & 7 deletions scripts/actors/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ var _progress := 1.0
var _moving := false
var _attack_timer := 0.0
var _sprite: Sprite2D = null
var _light: PointLight2D = null

func setup(
p_stats: CombatStats,
Expand All @@ -51,13 +52,23 @@ func setup(
view = p_view
occupancy = p_occupancy
position = view.tile_to_world(grid_pos)
_teardown_visuals()
_sprite = Sprite2D.new()
_sprite.texture = TileArt.entity_texture(&"player")
_sprite.centered = true
add_child(_sprite)
_add_light()
emit_hud()

## Removes the old sprite and light before a new floor reuses this node.
func _teardown_visuals() -> void:
if _sprite != null:
_sprite.queue_free()
_sprite = null
if _light != null:
_light.queue_free()
_light = null

func _physics_process(p_delta: float) -> void:
if view == null or stats == null:
return
Expand Down Expand Up @@ -145,6 +156,13 @@ func spend_key() -> void:
keys_held -= 1
keys_changed.emit(keys_held)

## Drops all held keys, used when the hero descends to a new floor.
func reset_keys() -> void:
if keys_held <= 0:
return
keys_held = 0
keys_changed.emit(0)

func add_key() -> void:
apply_pickup(&"key", 1)

Expand All @@ -160,13 +178,13 @@ func _flash() -> void:
tween.tween_property(_sprite, "modulate", Color.WHITE, 0.12)

func _add_light() -> void:
var light := PointLight2D.new()
light.texture = _soft_light_texture()
light.energy = 1.5
light.texture_scale = 8.0
light.color = Color(1.0, 0.94, 0.8)
light.shadow_enabled = false
add_child(light)
_light = PointLight2D.new()
_light.texture = _soft_light_texture()
_light.energy = 1.5
_light.texture_scale = 8.0
_light.color = Color(1.0, 0.94, 0.8)
_light.shadow_enabled = false
add_child(_light)

func _soft_light_texture() -> Texture2D:
var size := 64
Expand Down
Loading
Loading