diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4e5e2d..c46a237 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index ec17257..ec0b221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9c733e9..fc10d3e 100644 --- a/README.md +++ b/README.md @@ -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.############ @@ -18,12 +18,15 @@ 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. @@ -31,20 +34,14 @@ The same seed always builds the same dungeon. - 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 @@ -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. @@ -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. @@ -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. @@ -121,25 +141,33 @@ 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. @@ -147,7 +175,7 @@ Next up: ## 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. diff --git a/docs/architecture.md b/docs/architecture.md index df3db0f..ff1aec0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/project.godot b/project.godot index 0afbba8..8632b5e 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scenes/main.tscn b/scenes/main.tscn index 6b839ea..45b507c 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -1,4 +1,4 @@ -[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"] @@ -6,6 +6,7 @@ [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") @@ -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 diff --git a/scripts/actors/monster_actor.gd b/scripts/actors/monster_actor.gd index 8ceebe2..bb75575 100644 --- a/scripts/actors/monster_actor.gd +++ b/scripts/actors/monster_actor.gd @@ -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, diff --git a/scripts/actors/player.gd b/scripts/actors/player.gd index 5fa8967..8509f82 100644 --- a/scripts/actors/player.gd +++ b/scripts/actors/player.gd @@ -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, @@ -51,6 +52,7 @@ 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 @@ -58,6 +60,15 @@ func setup( _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 @@ -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) @@ -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 diff --git a/scripts/core/run_progression.gd b/scripts/core/run_progression.gd new file mode 100644 index 0000000..95f0cab --- /dev/null +++ b/scripts/core/run_progression.gd @@ -0,0 +1,43 @@ +class_name RunProgression +extends RefCounted +## Run-level rules for the multi-floor descent. +## +## A run is a descent through a fixed number of floors. Every floor gets +## its own map, its own biome, and a seed derived from the run seed. +## The class holds the pure math that makes the descent deterministic +## and testable without touching the scene tree. + +## How many floors a run covers. The exit of the last floor is the goal. +const TOTAL_FLOORS := 3 + +## Health restored to the hero when they descend to the next floor. +const DESCEND_HEAL := 25 + +## Returns the seed used to generate a specific floor of a run. +static func floor_seed(p_run_seed: int, p_floor: int) -> int: + return (p_run_seed ^ (p_floor * 0x5EED4F2)) & SeededRng.SEED_MASK + +## Returns the biome that governs a floor. +## The choice depends only on the run seed and the floor number. +static func biome_for_floor(p_run_seed: int, p_floor: int) -> DungeonConfig: + return Biomes.random(SeededRng.new(floor_seed(p_run_seed, p_floor))) + +## True when the floor is the last floor of a run. +static func is_final_floor(p_floor: int) -> bool: + return p_floor >= TOTAL_FLOORS - 1 + +## Multiplier for monster hit points on a floor. +static func monster_health_scale(p_floor: int) -> float: + return 1.0 + float(p_floor) * 0.3 + +## Multiplier for monster damage on a floor. +static func monster_damage_scale(p_floor: int) -> float: + return 1.0 + float(p_floor) * 0.2 + +## Multiplier for the share of rooms that hold monsters. +static func density_multiplier(p_floor: int) -> float: + return 1.0 + float(p_floor) * 0.15 + +## Extra monsters the generator may place on a floor. +static func cap_bonus(p_floor: int) -> int: + return p_floor * 2 diff --git a/scripts/core/run_progression.gd.uid b/scripts/core/run_progression.gd.uid new file mode 100644 index 0000000..0a49b3b --- /dev/null +++ b/scripts/core/run_progression.gd.uid @@ -0,0 +1 @@ +uid://cpuxjakgqoet4 diff --git a/scripts/core/run_state.gd b/scripts/core/run_state.gd index 2f8f9ef..dfe4ba9 100644 --- a/scripts/core/run_state.gd +++ b/scripts/core/run_state.gd @@ -11,6 +11,8 @@ static var seed_value: int = 0 static var seed_string: String = "" static var biome_id: StringName = &"" static var status: RunStatus = RunStatus.IDLE +static var floor: int = 0 +static var total_floors: int = RunProgression.TOTAL_FLOORS static var started_at: float = 0.0 static var finished_at: float = 0.0 diff --git a/scripts/dungeon/dungeon_generator.gd b/scripts/dungeon/dungeon_generator.gd index f25442d..70d57ff 100644 --- a/scripts/dungeon/dungeon_generator.gd +++ b/scripts/dungeon/dungeon_generator.gd @@ -13,9 +13,10 @@ extends RefCounted ## 7. Scatter monsters in the rooms. ## ## Every step uses the same SeededRng, so a seed always produces the -## same dungeon. +## same dungeon. The optional floor number scales monster pressure: +## deeper floors hold more monsters. Floor zero is neutral. -func generate(p_config: DungeonConfig, p_seed: int) -> DungeonResult: +func generate(p_config: DungeonConfig, p_seed: int, p_floor: int = 0) -> DungeonResult: var result := DungeonResult.new() result.seed_value = p_seed result.config = p_config @@ -37,7 +38,7 @@ func generate(p_config: DungeonConfig, p_seed: int) -> DungeonResult: result.map.set_tile_cell(result.exit_pos, DungeonMap.Tile.EXIT) _place_doors_and_keys(rng, p_config, result) - _place_monsters(rng, p_config, result) + _place_monsters(rng, p_config, result, p_floor) result.depth = Pathfinding.flood(result.map, result.start_pos, true).get(result.exit_pos, 0) result.solvable = Solvability.verify(result) @@ -455,7 +456,8 @@ func _rooms_on_start_side( func _place_monsters( p_rng: SeededRng, p_config: DungeonConfig, - p_result: DungeonResult + p_result: DungeonResult, + p_floor: int = 0 ) -> void: var monster_ids: Array = [] var weights: Array = [] @@ -463,6 +465,8 @@ func _place_monsters( monster_ids.append(entry.monster) weights.append(entry.weight) + var cap := p_config.monster_cap + RunProgression.cap_bonus(p_floor) + var monster_rooms: Array[int] = [] for room in p_result.rooms: if room.id == p_result.start_room or room.id == p_result.exit_room: @@ -470,13 +474,13 @@ func _place_monsters( monster_rooms.append(room.id) for room_id in monster_rooms: - if p_result.monster_spawns.size() >= p_config.monster_cap: + if p_result.monster_spawns.size() >= cap: break var room := p_result.rooms[room_id] - var density := p_config.monster_density + var density := p_config.monster_density * RunProgression.density_multiplier(p_floor) if room.area() >= 56: density += 0.2 - if not p_rng.chance(density): + if not p_rng.chance(minf(1.0, density)): continue var monster_id: StringName = monster_ids[p_rng.weighted_index(weights)] p_result.monster_spawns.append({ diff --git a/scripts/main.gd b/scripts/main.gd index 9c87a13..28397c6 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -17,6 +17,7 @@ signal run_started(seed_value: int) @onready var camera: Camera2D = $Camera @onready var hud: Hud = $UI/HUD @onready var menu_overlay: MenuOverlay = $UI/MenuOverlay +@onready var descend_overlay: DescendOverlay = $UI/DescendOverlay @onready var result_overlay: ResultOverlay = $UI/ResultOverlay @onready var ambient: CanvasModulate = $Ambient @@ -27,8 +28,10 @@ var run: DungeonResult = null var biome: DungeonConfig = null var occupancy: Dictionary = {} var run_seed: int = 0 +var current_floor: int = 0 var monster_index := 0 var _ended := false +var _at_exit := false var _beacon_phase := 0.0 var _beacon: Sprite2D = null @@ -46,6 +49,8 @@ func _wire_signals() -> void: player.moved.connect(_on_player_moved) menu_overlay.start_requested.connect(_on_start_requested) menu_overlay.continue_requested.connect(_resume) + descend_overlay.descend_requested.connect(_on_descend_requested) + descend_overlay.stay_requested.connect(_on_stay_requested) result_overlay.new_run_requested.connect(_on_new_run_requested) func _unhandled_input(p_event: InputEvent) -> void: @@ -55,7 +60,8 @@ func _unhandled_input(p_event: InputEvent) -> void: if p_event.is_action_pressed("toggle_minimap") and run != null: hud.toggle_minimap() return - if p_event.is_action_pressed("pause") and run != null and not result_overlay.visible: + if p_event.is_action_pressed("pause") and run != null \ + and not result_overlay.visible and not descend_overlay.visible: _toggle_pause() func _physics_process(p_delta: float) -> void: @@ -65,8 +71,12 @@ func _physics_process(p_delta: float) -> void: hud.update_marker(player.grid_pos) _collect_pickups() _pulse_beacon(p_delta) - if not _ended and player.grid_pos == run.exit_pos: - _on_victory() + if _at_exit: + if player.grid_pos != run.exit_pos: + _at_exit = false + elif player.grid_pos == run.exit_pos: + _at_exit = true + _on_reach_exit() func _on_start_requested(p_seed_text: String) -> void: var text := p_seed_text.strip_edges() @@ -92,20 +102,43 @@ func start_run(p_seed_value: int) -> void: func _start_run(p_seed_value: int) -> void: run_seed = p_seed_value - biome = Biomes.random(SeededRng.new(p_seed_value ^ 0x5EED)) - run = DungeonGenerator.new().generate(biome, p_seed_value) + current_floor = 0 + descend_overlay.hide_descend() + result_overlay.hide_result() + _begin_floor(false) + +## Descends to the next floor. Public entry point for tooling. +func descend() -> void: + if _ended or current_floor >= RunProgression.TOTAL_FLOORS - 1: + return + descend_overlay.hide_descend() + current_floor += 1 + _begin_floor(true) + +## Builds the world for the current floor. Keeps the hero's progress +## when p_keep_progress is true, otherwise starts a fresh hero. +func _begin_floor(p_keep_progress: bool) -> void: + biome = RunProgression.biome_for_floor(run_seed, current_floor) + var floor_seed := RunProgression.floor_seed(run_seed, current_floor) + run = DungeonGenerator.new().generate(biome, floor_seed, current_floor) _clear_world() occupancy.clear() _ended = false + _at_exit = false dungeon_view.configure(run.map, biome) - var stats := CombatStats.make({ - "max_health": biome.starting_health, - "health": biome.starting_health, - "damage": biome.player_damage, - }) - player.setup(stats, run.start_pos, dungeon_view, occupancy) + if p_keep_progress and player.stats != null: + player.stats.heal(RunProgression.DESCEND_HEAL) + player.reset_keys() + player.setup(player.stats, run.start_pos, dungeon_view, occupancy) + else: + var stats := CombatStats.make({ + "max_health": biome.starting_health, + "health": biome.starting_health, + "damage": biome.player_damage, + }) + player.setup(stats, run.start_pos, dungeon_view, occupancy) occupancy[run.start_pos] = player player.can_enter = _hero_can_enter @@ -123,25 +156,44 @@ func _start_run(p_seed_value: int) -> void: _apply_camera_limits() ambient.color = Color(biome.palette.get(&"accent", Color.WHITE)).darkened(0.55) - hud.set_run(biome.display_name, SeededRng.encode_seed(p_seed_value), run.depth) + var exit_is_goal := RunProgression.is_final_floor(current_floor) + hud.set_run( + current_floor, + RunProgression.TOTAL_FLOORS, + biome.display_name, + SeededRng.encode_seed(run_seed), + run.depth, + exit_is_goal + ) hud.set_minimap(dungeon_view.build_minimap_image(), run.map.width, run.map.height) - RunState.seed_value = p_seed_value - RunState.seed_string = SeededRng.encode_seed(p_seed_value) + RunState.seed_value = run_seed + RunState.seed_string = SeededRng.encode_seed(run_seed) RunState.biome_id = biome.id + RunState.floor = current_floor + RunState.total_floors = RunProgression.TOTAL_FLOORS RunState.status = RunState.RunStatus.ACTIVE - RunState.started_at = Time.get_ticks_msec() / 1000.0 + if not p_keep_progress: + RunState.started_at = Time.get_ticks_msec() / 1000.0 menu_overlay.hide_menu() - result_overlay.hide_result() + descend_overlay.hide_descend() get_tree().paused = false - run_started.emit(p_seed_value) + run_started.emit(run_seed) func _spawn_monster(p_position: Vector2i, p_monster_id: StringName) -> void: var spec := MonsterSpecs.by_id(p_monster_id) var monster: MonsterActor = MONSTER_SCENE.instantiate() monsters_root.add_child(monster) - monster.setup(spec, p_position, dungeon_view, occupancy, player) + monster.setup( + spec, + p_position, + dungeon_view, + occupancy, + player, + RunProgression.monster_health_scale(current_floor), + RunProgression.monster_damage_scale(current_floor) + ) occupancy[p_position] = monster monster.attack_player.connect(_on_monster_attack) monster.died.connect(_on_monster_died) @@ -201,26 +253,50 @@ func _on_player_died() -> void: if _ended: return _ended = true + _at_exit = false RunState.status = RunState.RunStatus.LOST RunState.finished_at = Time.get_ticks_msec() / 1000.0 result_overlay.show_result( false, SeededRng.encode_seed(run_seed), + current_floor, + RunProgression.TOTAL_FLOORS, run.depth, player.coins, RunState.elapsed() ) get_tree().paused = true +## Called when the hero steps on the exit tile of a floor. +func _on_reach_exit() -> void: + if _ended: + return + if RunProgression.is_final_floor(current_floor): + _on_victory() + return + get_tree().paused = true + descend_overlay.show_descend(current_floor, RunProgression.TOTAL_FLOORS, RunProgression.DESCEND_HEAL) + +func _on_descend_requested() -> void: + descend() + +## The hero chooses to stay. Let them step off and return later. +func _on_stay_requested() -> void: + descend_overlay.hide_descend() + get_tree().paused = false + func _on_victory() -> void: if _ended: return _ended = true + _at_exit = false RunState.status = RunState.RunStatus.WON RunState.finished_at = Time.get_ticks_msec() / 1000.0 result_overlay.show_result( true, SeededRng.encode_seed(run_seed), + current_floor, + RunProgression.TOTAL_FLOORS, run.depth, player.coins, RunState.elapsed() @@ -247,7 +323,8 @@ func _apply_camera_limits() -> void: func _spawn_exit_beacon() -> void: _beacon = Sprite2D.new() _beacon.texture = _beacon_texture() - _beacon.modulate = Color(biome.palette.get(&"glow", Color.WHITE)) + var key: StringName = &"glow" if RunProgression.is_final_floor(current_floor) else &"rune" + _beacon.modulate = Color(biome.palette.get(key, Color.WHITE)) _beacon.scale = Vector2(0.5, 0.5) _beacon.position = dungeon_view.tile_to_world(run.exit_pos) effects_root.add_child(_beacon) diff --git a/scripts/ui/descend_overlay.gd b/scripts/ui/descend_overlay.gd new file mode 100644 index 0000000..d57804b --- /dev/null +++ b/scripts/ui/descend_overlay.gd @@ -0,0 +1,84 @@ +class_name DescendOverlay +extends Control +## The stairwell prompt between floors. +## +## When the hero reaches the exit of a floor, this overlay asks whether +## to descend. The hero keeps coins and health, and keys reset. The same +## overlay never appears on the final floor, where the exit is the goal. + +signal descend_requested +signal stay_requested + +var _title: Label = null +var _summary: Label = null +var _descend_button: Button = null +var _stay_button: Button = null + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + visible = false + _build() + +## Shows the prompt with the current floor numbers and heal amount. +func show_descend(p_floor: int, p_total: int, p_heal: int) -> void: + visible = true + _title.text = "Stairwell found" + _summary.text = "Floor %d of %d complete.\nCoins and health carry over.\nKeys reset. Descending heals %d HP." % [ + p_floor + 1, p_total, p_heal, + ] + _descend_button.grab_focus() + +func hide_descend() -> void: + visible = false + +func _unhandled_input(p_event: InputEvent) -> void: + if visible and p_event.is_action_pressed("pause"): + stay_requested.emit() + get_viewport().set_input_as_handled() + +func _build() -> void: + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.8) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(dim) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(400, 0) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 28) + margin.add_theme_constant_override("margin_right", 28) + margin.add_theme_constant_override("margin_top", 24) + margin.add_theme_constant_override("margin_bottom", 24) + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 12) + + _title = Label.new() + _title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _title.add_theme_font_size_override("font_size", 36) + + _summary = Label.new() + _summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _summary.add_theme_font_size_override("font_size", 15) + + _descend_button = Button.new() + _descend_button.text = "Descend" + _descend_button.custom_minimum_size = Vector2(0, 36) + + _stay_button = Button.new() + _stay_button.text = "Stay on this floor" + _stay_button.custom_minimum_size = Vector2(0, 36) + + box.add_child(_title) + box.add_child(_summary) + box.add_child(_descend_button) + box.add_child(_stay_button) + + margin.add_child(box) + panel.add_child(margin) + center.add_child(panel) + add_child(center) + + _descend_button.pressed.connect(func() -> void: descend_requested.emit()) + _stay_button.pressed.connect(func() -> void: stay_requested.emit()) diff --git a/scripts/ui/descend_overlay.gd.uid b/scripts/ui/descend_overlay.gd.uid new file mode 100644 index 0000000..0345420 --- /dev/null +++ b/scripts/ui/descend_overlay.gd.uid @@ -0,0 +1 @@ +uid://bcuxoy1gkgf8s diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 26a9113..bd685cf 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -7,6 +7,7 @@ extends Control var biome_label: Label = null var seed_label: Label = null +var floor_label: Label = null var depth_label: Label = null var hp_fill: ColorRect = null var hp_label: Label = null @@ -27,10 +28,18 @@ func _ready() -> void: _build_bottom_panel() _build_minimap() -func set_run(p_biome: String, p_seed: String, p_depth: int) -> void: +func set_run( + p_floor: int, + p_total: int, + p_biome: String, + p_seed: String, + p_depth: int, + p_exit_is_goal: bool +) -> void: + floor_label.text = "Floor %d of %d" % [p_floor + 1, p_total] biome_label.text = "Biome: " + p_biome seed_label.text = "Seed: " + p_seed - depth_label.text = "Exit at depth " + str(p_depth) + depth_label.text = ("Goal at depth " if p_exit_is_goal else "Stairs at depth ") + str(p_depth) func set_hp(p_current: int, p_max: int) -> void: if hp_fill == null: @@ -72,9 +81,11 @@ func _build_top_panel() -> void: panel.add_child(box) biome_label = _label("", 15) seed_label = _label("", 15) + floor_label = _label("", 15) depth_label = _label("", 15) box.add_child(biome_label) box.add_child(seed_label) + box.add_child(floor_label) box.add_child(depth_label) add_child(panel) diff --git a/scripts/ui/menu_overlay.gd b/scripts/ui/menu_overlay.gd index 0e6e670..d3d7c67 100644 --- a/scripts/ui/menu_overlay.gd +++ b/scripts/ui/menu_overlay.gd @@ -67,7 +67,7 @@ func _build() -> void: title.add_theme_font_size_override("font_size", 40) var subtitle := Label.new() - subtitle.text = "A new dungeon for every run. Every run can repeat from its seed." + subtitle.text = "A new dungeon for every run. Descend three floors to reach the goal." subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART subtitle.add_theme_font_size_override("font_size", 14) diff --git a/scripts/ui/result_overlay.gd b/scripts/ui/result_overlay.gd index b6417e5..5c6b4c5 100644 --- a/scripts/ui/result_overlay.gd +++ b/scripts/ui/result_overlay.gd @@ -17,14 +17,22 @@ func _ready() -> void: visible = false _build() -func show_result(p_won: bool, p_seed: String, p_depth: int, p_coins: int, p_time: float) -> void: +func show_result( + p_won: bool, + p_seed: String, + p_floor: int, + p_total: int, + p_depth: int, + p_coins: int, + p_time: float +) -> void: visible = true _title.text = "Victory" if p_won else "Defeat" _title.add_theme_color_override("font_color", Color("#7fe8b9") if p_won else Color("#e07070")) var minutes := int(p_time) / 60 var seconds := int(p_time) % 60 - _summary.text = "Seed: %s\nDepth: %d\nCoins: %d\nTime: %d:%02d" % [ - p_seed, p_depth, p_coins, minutes, seconds, + _summary.text = "Seed: %s\nFloor: %d of %d\nDepth: %d\nCoins: %d\nTime: %d:%02d" % [ + p_seed, p_floor + 1, p_total, p_depth, p_coins, minutes, seconds, ] _same_seed_button.visible = p_won _new_button.grab_focus() diff --git a/tests/integration/test_multi_floor.gd b/tests/integration/test_multi_floor.gd new file mode 100644 index 0000000..27834b4 --- /dev/null +++ b/tests/integration/test_multi_floor.gd @@ -0,0 +1,92 @@ +extends GutTest +## Multi-floor runs: deterministic sequences, solvable floors, and scale. + +func test_run_replays_identical_floor_sequence() -> void: + var first := _build_floors(12345) + var second := _build_floors(12345) + assert_eq(first.size(), RunProgression.TOTAL_FLOORS) + for floor in range(first.size()): + assert_eq(first[floor].config.id, second[floor].config.id) + assert_eq(first[floor].map._cells, second[floor].map._cells) + +func test_floor_maps_differ_within_a_run() -> void: + var floors := _build_floors(909) + assert_ne(floors[0].map._cells, floors[1].map._cells) + assert_ne(floors[1].map._cells, floors[2].map._cells) + +func test_every_floor_is_solvable() -> void: + for run_seed in [7, 8, 9, 10]: + for floor in _build_floors(run_seed): + assert_true(floor.solvable, "run %d has an unsolvable floor" % run_seed) + +func test_start_and_exit_are_valid_on_every_floor() -> void: + for floor in _build_floors(31337): + assert_true(floor.map.is_walkable_cell(floor.start_pos)) + assert_true(floor.map.is_walkable_cell(floor.exit_pos)) + assert_true(Pathfinding.reaches(floor.map, floor.start_pos, floor.exit_pos, true)) + +func test_floor_zero_generation_matches_single_floor_run() -> void: + var run_seed := 8080 + var biome := RunProgression.biome_for_floor(run_seed, 0) + var through_progression := DungeonGenerator.new().generate( + biome, RunProgression.floor_seed(run_seed, 0), 0 + ) + var direct := DungeonGenerator.new().generate(biome, RunProgression.floor_seed(run_seed, 0)) + assert_eq(through_progression.map._cells, direct.map._cells) + assert_eq(through_progression.monster_spawns.size(), direct.monster_spawns.size()) + +func test_deeper_floors_hold_more_monsters() -> void: + var counts := [0, 0, 0] + for run_seed in range(1, 13): + var floors := _build_floors(run_seed) + for floor in range(floors.size()): + counts[floor] += floors[floor].monster_count() + assert_gt(counts[2], counts[0], "deep floors should host more monsters") + assert_gt(counts[2], counts[1]) + +func test_monster_stats_scale_with_floor() -> void: + var spec := MonsterSpecs.by_id(&"skeleton") + var floor_zero_hp := maxi(1, roundi(spec.stats.max_health * RunProgression.monster_health_scale(0))) + var floor_two_hp := maxi(1, roundi(spec.stats.max_health * RunProgression.monster_health_scale(2))) + assert_gt(floor_two_hp, floor_zero_hp) + +func test_actor_spawn_applies_floor_scale() -> void: + var view := DungeonView.new() + add_child_autofree(view) + await wait_frames(1) + var run_seed := 55 + var floor := 2 + var biome := RunProgression.biome_for_floor(run_seed, floor) + var result := DungeonGenerator.new().generate( + biome, RunProgression.floor_seed(run_seed, floor), floor + ) + view.configure(result.map, biome) + var monster: MonsterActor = preload("res://scenes/actors/monster.tscn").instantiate() + add_child_autofree(monster) + var spec := MonsterSpecs.by_id(&"skeleton") + monster.setup( + spec, + result.start_pos, + view, + {}, + null, + RunProgression.monster_health_scale(floor), + RunProgression.monster_damage_scale(floor) + ) + assert_eq( + monster.stats.max_health, + maxi(1, roundi(spec.stats.max_health * RunProgression.monster_health_scale(floor))) + ) + assert_eq( + monster.stats.damage, + maxi(1, roundi(spec.stats.damage * RunProgression.monster_damage_scale(floor))) + ) + +func _build_floors(p_run_seed: int) -> Array[DungeonResult]: + var floors: Array[DungeonResult] = [] + for floor in range(RunProgression.TOTAL_FLOORS): + var biome := RunProgression.biome_for_floor(p_run_seed, floor) + floors.append(DungeonGenerator.new().generate( + biome, RunProgression.floor_seed(p_run_seed, floor), floor + )) + return floors diff --git a/tests/integration/test_multi_floor.gd.uid b/tests/integration/test_multi_floor.gd.uid new file mode 100644 index 0000000..e2c7627 --- /dev/null +++ b/tests/integration/test_multi_floor.gd.uid @@ -0,0 +1 @@ +uid://denc2dtaevot3 diff --git a/tests/unit/test_run_progression.gd b/tests/unit/test_run_progression.gd new file mode 100644 index 0000000..a08c3d6 --- /dev/null +++ b/tests/unit/test_run_progression.gd @@ -0,0 +1,61 @@ +extends GutTest +## RunProgression: deterministic floor seeds, biome choice, and scaling. + +func test_floor_seed_is_deterministic() -> void: + assert_eq(RunProgression.floor_seed(123, 2), RunProgression.floor_seed(123, 2)) + +func test_floor_seed_changes_with_floor() -> void: + assert_ne(RunProgression.floor_seed(777, 0), RunProgression.floor_seed(777, 1)) + assert_ne(RunProgression.floor_seed(777, 1), RunProgression.floor_seed(777, 2)) + +func test_floor_seed_stays_in_seed_mask() -> void: + for run_seed in [1, 7, 999, 123456]: + for floor in range(0, RunProgression.TOTAL_FLOORS): + var value := RunProgression.floor_seed(run_seed, floor) + assert_lte(value, SeededRng.SEED_MASK) + assert_gte(value, 0) + +func test_floor_zero_seed_equals_run_seed() -> void: + assert_eq(RunProgression.floor_seed(4242, 0), 4242 & SeededRng.SEED_MASK) + +func test_biome_for_floor_is_deterministic() -> void: + for run_seed in [1, 2, 3]: + for floor in range(0, RunProgression.TOTAL_FLOORS): + var first := RunProgression.biome_for_floor(run_seed, floor) + var second := RunProgression.biome_for_floor(run_seed, floor) + assert_eq(first.id, second.id) + +func test_every_floor_biome_is_valid() -> void: + for run_seed in [5, 6, 7, 8]: + for floor in range(0, RunProgression.TOTAL_FLOORS): + var biome := RunProgression.biome_for_floor(run_seed, floor) + assert_true(biome.is_valid(), "run %d floor %d invalid" % [run_seed, floor]) + +func test_biome_choice_covers_multiple_biomes() -> void: + var seen := {} + for run_seed in range(1, 31): + for floor in range(0, RunProgression.TOTAL_FLOORS): + seen[RunProgression.biome_for_floor(run_seed, floor).id] = true + assert_gte(seen.size(), 2, "the descent should mix biomes") + +func test_is_final_floor() -> void: + assert_true(RunProgression.is_final_floor(RunProgression.TOTAL_FLOORS - 1)) + assert_false(RunProgression.is_final_floor(0)) + assert_false(RunProgression.is_final_floor(RunProgression.TOTAL_FLOORS - 2)) + +func test_floor_zero_scales_are_neutral() -> void: + assert_eq(RunProgression.monster_health_scale(0), 1.0) + assert_eq(RunProgression.monster_damage_scale(0), 1.0) + assert_eq(RunProgression.density_multiplier(0), 1.0) + assert_eq(RunProgression.cap_bonus(0), 0) + +func test_monster_scales_grow_with_floor() -> void: + assert_lt(RunProgression.monster_health_scale(0), RunProgression.monster_health_scale(1)) + assert_lt(RunProgression.monster_health_scale(1), RunProgression.monster_health_scale(2)) + assert_lt(RunProgression.monster_damage_scale(0), RunProgression.monster_damage_scale(1)) + assert_lt(RunProgression.density_multiplier(0), RunProgression.density_multiplier(2)) + assert_gt(RunProgression.cap_bonus(2), RunProgression.cap_bonus(0)) + +func test_total_floors_is_reasonable() -> void: + assert_gt(RunProgression.TOTAL_FLOORS, 1) + assert_lt(RunProgression.TOTAL_FLOORS, 10) diff --git a/tests/unit/test_run_progression.gd.uid b/tests/unit/test_run_progression.gd.uid new file mode 100644 index 0000000..dbe4622 --- /dev/null +++ b/tests/unit/test_run_progression.gd.uid @@ -0,0 +1 @@ +uid://6lnsgxcyhd3g diff --git a/tools/smoke.gd b/tools/smoke.gd index 3b50254..290c9f0 100644 --- a/tools/smoke.gd +++ b/tools/smoke.gd @@ -48,6 +48,33 @@ func _verify() -> void: elif not _main.run.solvable: _fail("dungeon is not solvable") _verify_input_bindings() + _verify_descent() + +## The descent must advance the floor, keep coins, and reset keys. +func _verify_descent() -> void: + var first_cells := _main.run.map._cells.duplicate() + _main.player.coins = 7 + _main.player.keys_held = 3 + _main.descend() + if _main.current_floor != 1: + _fail("descent did not advance the floor") + elif _main.run == null: + _fail("descent did not generate a floor") + elif _main.run.map._cells == first_cells: + _fail("descent reused the previous map") + elif _main.player.coins != 7: + _fail("coins did not carry over the descent") + elif _main.player.keys_held != 0: + _fail("keys did not reset on descent") + elif not _main.run.solvable: + _fail("descended floor is not solvable") + + if _failed: + print("[smoke] FAILED") + quit(1) + else: + print("Smoke test passed: seed 12345 spawned a solvable dungeon.") + quit(0) func _verify_input_bindings() -> void: for action in Controls.ACTIONS: @@ -61,13 +88,6 @@ func _verify_input_bindings() -> void: if Controls.hint_for(false).is_empty() or Controls.hint_for(true).is_empty(): _fail("control hints are empty") - if _failed: - print("[smoke] FAILED") - quit(1) - else: - print("Smoke test passed: seed 12345 spawned a solvable dungeon.") - quit(0) - func _fail(p_message: String) -> void: _failed = true push_error("[smoke] " + p_message)