From ddf276a61447dce94b8a46707fa6594d669d3631 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:49:30 -0700 Subject: [PATCH] feat: extend dungeonwright --- .gitattributes | 1 + .github/dependabot.yml | 10 ++++ .github/workflows/ci.yml | 2 + CHANGELOG.md | 13 ++++ README.md | 38 +++++++----- docs/architecture.md | 21 ++++++- scripts/actors/player.gd | 20 +++++-- scripts/core/descent.gd | 27 +++++++++ scripts/core/descent.gd.uid | 1 + scripts/core/run_state.gd | 6 ++ scripts/dungeon/biomes.gd | 3 + scripts/dungeon/dungeon_config.gd | 4 ++ scripts/dungeon/dungeon_generator.gd | 15 +++-- scripts/dungeon/dungeon_result.gd | 8 +++ scripts/input/controls.gd | 8 +-- scripts/main.gd | 68 +++++++++++++++++---- scripts/ui/hud.gd | 12 +++- scripts/ui/result_overlay.gd | 14 ++++- scripts/ui/transition_overlay.gd | 73 +++++++++++++++++++++++ scripts/ui/transition_overlay.gd.uid | 1 + tests/integration/test_descent_run.gd | 52 ++++++++++++++++ tests/integration/test_descent_run.gd.uid | 1 + tests/unit/test_descent.gd | 63 +++++++++++++++++++ tests/unit/test_descent.gd.uid | 1 + tools/smoke.gd | 70 ++++++++++++++++++---- 25 files changed, 477 insertions(+), 55 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 scripts/core/descent.gd create mode 100644 scripts/core/descent.gd.uid create mode 100644 scripts/ui/transition_overlay.gd create mode 100644 scripts/ui/transition_overlay.gd.uid create mode 100644 tests/integration/test_descent_run.gd create mode 100644 tests/integration/test_descent_run.gd.uid create mode 100644 tests/unit/test_descent.gd create mode 100644 tests/unit/test_descent.gd.uid diff --git a/.gitattributes b/.gitattributes index 66fb254..ddc6f21 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,4 +6,5 @@ *.md text eol=lf *.sh text eol=lf *.ps1 text eol=lf +*.yml text eol=lf *.svg text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fe8d274 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + labels: + - "ci" + - "dependencies" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4e5e2d..ff0b460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read @@ -15,6 +16,7 @@ concurrency: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Check out the repository uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index ec17257..c8e8a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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 that descend into a new dungeon per floor. +- A deterministic floor seed chain from a single run seed. +- A floor counter in the HUD and on the result screen. +- A floor-cleared transition overlay. +- The hero keeps coins and shards between floors. +- The exit only ends the run on the final floor. +- Unit and integration tests for the descent chain. +- A smoke test that clears every floor of a run. + ## [0.2.0] - 2026-08-03 Added diff --git a/README.md b/README.md index 9c733e9..aa3ba78 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,13 @@ 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. +The same seed always builds the same run. +The run descends through several floors. ## Features - A new map for every run, driven by a seed. +- Multi-floor runs that descend into a new dungeon. - Rooms, corridors, locked doors, and keys. - Three biomes with different generation rules. - Monsters with simple combat and balanced drops. @@ -40,11 +42,12 @@ 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 a multi-floor descent. +Every run now spans several floors. +The exit on a floor leads down to the next floor. +One seed replays the whole run, floor by floor. +Coins and shards carry over between floors. +The run ends when you clear the final floor. ## Requirements @@ -68,6 +71,7 @@ Leave the field empty for a random seed. Move with WASD or the arrow keys. Attack with Space, J, or a mouse click. +Reach the exit to descend to the next floor. Press N for a new run. Press M to toggle the minimap. Press Escape to pause. @@ -106,10 +110,17 @@ 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 has more than one floor. +Each floor builds a fresh dungeon from a child seed. +The child seed comes from the run seed through a fixed mixer. +So one seed replays every floor in the same order. +Coins and shards carry over between floors. + ## Project layout - `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 seed chain. - `scripts/input` holds the controls helper. - `scripts/world` renders tiles and builds the minimap. - `scripts/actors` holds the hero, monsters, and pickups. @@ -120,26 +131,25 @@ 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 floors. 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. -A smoke test loads the game and spawns a fixed-seed run. +The suite has 81 tests. +It covers generation, biomes, descent, combat, drops, pathfinding, and input. +All 81 tests pass in a headless run. +A smoke test loads the game and clears a fixed-seed run floor by floor. The smoke test also checks every action has a gamepad binding. ## Roadmap Done in this release: -- Full gamepad support. +- Multi-floor descent with a floor counter. Next up: -- Multi-floor descent and a depth counter. - Ranged monsters and projectiles. - Sound and music. - More biomes and items. @@ -147,7 +157,7 @@ Next up: ## Limitations The demo has three biomes. -Each run is a single floor. +Each run stays inside one biome. All monsters use melee attacks. The game has no audio yet. diff --git a/docs/architecture.md b/docs/architecture.md index df3db0f..eedb789 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,6 +72,24 @@ 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 descent + +A run covers several floors. +The `Descent` class derives one floor seed per floor from the run seed. +It mixes the previous floor seed with a fixed hash. +Floor one uses the run seed unchanged. + +Every floor calls the same generator with its own floor seed. +So one seed replays every floor in the same order. +The generator result reports the run seed and the floor number. +The final floor is the one that matches the biome floor count. + +The scene controller keeps the hero between floors. +Coins and shards carry over. +Keys reset, because each floor has its own doors. +Reaching the exit descends to the next floor. +Reaching the exit on the final floor wins the run. + ## Combat model The player and each monster carry a `CombatStats` block. @@ -111,9 +129,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. +Unit tests cover the RNG, generator, biomes, descent, combat, and drops. Integration tests run many seeds across all biomes. Every generated dungeon must be solvable. +Descent tests prove every floor of a run stays solvable and distinct. Run the suite with `tools/run_tests`. CI runs the same commands on every push. diff --git a/scripts/actors/player.gd b/scripts/actors/player.gd index 5fa8967..2ea899a 100644 --- a/scripts/actors/player.gd +++ b/scripts/actors/player.gd @@ -40,6 +40,8 @@ var _moving := false var _attack_timer := 0.0 var _sprite: Sprite2D = null +## Prepares the hero for a floor. Call once per floor. +## The hero keeps its coins and shards, and its visuals are created only once. func setup( p_stats: CombatStats, p_start: Vector2i, @@ -51,11 +53,12 @@ func setup( view = p_view occupancy = p_occupancy position = view.tile_to_world(grid_pos) - _sprite = Sprite2D.new() - _sprite.texture = TileArt.entity_texture(&"player") - _sprite.centered = true - add_child(_sprite) - _add_light() + if _sprite == null: + _create_visuals() + _from = grid_pos + _to = grid_pos + _progress = 1.0 + _moving = false emit_hud() func _physics_process(p_delta: float) -> void: @@ -159,6 +162,13 @@ func _flash() -> void: tween.tween_property(_sprite, "modulate", Color(3.0, 0.4, 0.4), 0.08) tween.tween_property(_sprite, "modulate", Color.WHITE, 0.12) +func _create_visuals() -> void: + _sprite = Sprite2D.new() + _sprite.texture = TileArt.entity_texture(&"player") + _sprite.centered = true + add_child(_sprite) + _add_light() + func _add_light() -> void: var light := PointLight2D.new() light.texture = _soft_light_texture() diff --git a/scripts/core/descent.gd b/scripts/core/descent.gd new file mode 100644 index 0000000..bb769d7 --- /dev/null +++ b/scripts/core/descent.gd @@ -0,0 +1,27 @@ +class_name Descent +extends RefCounted +## Deterministic seed chain for a multi-floor run. +## +## A run starts from one seed. Every floor uses a child seed derived +## from the parent with a fixed mixer, so a single seed replays every +## floor in the same order. The chain never touches platform state. + +## Returns the seed for a floor in a run. Floors start at 1. +static func floor_seed(p_run_seed: int, p_floor: int) -> int: + var value := p_run_seed & SeededRng.SEED_MASK + if p_floor <= 1: + return value + for i in p_floor - 1: + value = _mix(value) + return value & SeededRng.SEED_MASK + +## The number of floors a run covers for the given biome. +static func floor_count(p_config: DungeonConfig) -> int: + return maxi(1, p_config.max_floors) + +## Mixes one floor seed into the next with a fixed hash. +static func _mix(p_value: int) -> int: + var x := (p_value + 0x9E3779B9) & 0xFFFFFFFF + x = ((x ^ (x >> 16)) * 0x21F0AAAD) & 0xFFFFFFFF + x = ((x ^ (x >> 15)) * 0x735A2D97) & 0xFFFFFFFF + return (x ^ (x >> 15)) & 0xFFFFFFFF diff --git a/scripts/core/descent.gd.uid b/scripts/core/descent.gd.uid new file mode 100644 index 0000000..b1c3743 --- /dev/null +++ b/scripts/core/descent.gd.uid @@ -0,0 +1 @@ +uid://ca4x1mful4slo diff --git a/scripts/core/run_state.gd b/scripts/core/run_state.gd index 2f8f9ef..9531ed7 100644 --- a/scripts/core/run_state.gd +++ b/scripts/core/run_state.gd @@ -13,6 +13,12 @@ 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 +## The current floor of the run. Floors start at 1. +static var floor: int = 1 +## The number of floors the run covers. +static var max_floors: int = 1 +## The number of floors the hero cleared so far. +static var floors_cleared: int = 0 ## Elapsed play time in seconds for the current run. static func elapsed() -> float: diff --git a/scripts/dungeon/biomes.gd b/scripts/dungeon/biomes.gd index 5711f56..a68d7b6 100644 --- a/scripts/dungeon/biomes.gd +++ b/scripts/dungeon/biomes.gd @@ -33,6 +33,7 @@ static func crypt() -> DungeonConfig: config.room_max = 9 config.corridor_style = DungeonConfig.CorridorStyle.elbow config.loop_chance = 0.25 + config.max_floors = 3 config.door_count_min = 2 config.door_count_max = 3 config.monster_density = 0.5 @@ -59,6 +60,7 @@ static func drowned_forest() -> DungeonConfig: config.room_max = 12 config.corridor_style = DungeonConfig.CorridorStyle.winding config.loop_chance = 0.5 + config.max_floors = 3 config.door_count_min = 1 config.door_count_max = 2 config.monster_density = 0.4 @@ -86,6 +88,7 @@ static func ember_stronghold() -> DungeonConfig: config.room_max = 8 config.corridor_style = DungeonConfig.CorridorStyle.straight config.loop_chance = 0.15 + config.max_floors = 4 config.door_count_min = 3 config.door_count_max = 4 config.monster_density = 0.65 diff --git a/scripts/dungeon/dungeon_config.gd b/scripts/dungeon/dungeon_config.gd index 3e5b9f7..a1c2c44 100644 --- a/scripts/dungeon/dungeon_config.gd +++ b/scripts/dungeon/dungeon_config.gd @@ -23,6 +23,8 @@ var room_min: int = 5 var room_max: int = 9 var corridor_style: StringName = CorridorStyle.elbow var loop_chance: float = 0.25 +## The number of floors a run covers in this biome. +var max_floors: int = 1 var door_count_min: int = 2 var door_count_max: int = 3 var monster_density: float = 0.5 @@ -47,6 +49,8 @@ func validate() -> Array[String]: problems.append("monster density must be between 0 and 1") if loop_chance < 0.0 or loop_chance > 1.0: problems.append("loop chance must be between 0 and 1") + if max_floors < 1: + problems.append("max floors must be at least 1") if monster_table.is_empty(): problems.append("monster table is empty") if palette.is_empty(): diff --git a/scripts/dungeon/dungeon_generator.gd b/scripts/dungeon/dungeon_generator.gd index f25442d..8089dad 100644 --- a/scripts/dungeon/dungeon_generator.gd +++ b/scripts/dungeon/dungeon_generator.gd @@ -1,6 +1,6 @@ class_name DungeonGenerator extends RefCounted -## Builds a solvable dungeon from a seed and a biome. +## Builds one floor of a solvable dungeon from a seed and a biome. ## ## The generator follows a fixed pipeline: ## 1. Place rooms without overlaps. @@ -13,11 +13,18 @@ extends RefCounted ## 7. Scatter monsters in the rooms. ## ## Every step uses the same SeededRng, so a seed always produces the -## same dungeon. - -func generate(p_config: DungeonConfig, p_seed: int) -> DungeonResult: +## same floor. Multi-floor runs pass p_floor and the base run seed so +## the result can report which floor it describes. +func generate( + p_config: DungeonConfig, + p_seed: int, + p_floor: int = 1, + p_run_seed: int = -1 +) -> DungeonResult: var result := DungeonResult.new() result.seed_value = p_seed + result.run_seed = p_seed if p_run_seed < 0 else p_run_seed + result.floor = p_floor result.config = p_config var rng := SeededRng.new(p_seed) diff --git a/scripts/dungeon/dungeon_result.gd b/scripts/dungeon/dungeon_result.gd index e70264c..d0657eb 100644 --- a/scripts/dungeon/dungeon_result.gd +++ b/scripts/dungeon/dungeon_result.gd @@ -6,6 +6,10 @@ extends RefCounted ## the start, the exit, keys, doors, and monster spawns. var seed_value: int = 0 +## The seed that started the whole run. It never changes between floors. +var run_seed: int = 0 +## The floor of the run that this result describes. Floors start at 1. +var floor: int = 1 var config: DungeonConfig = null var map: DungeonMap = null var rooms: Array[Room] = [] @@ -39,3 +43,7 @@ func key_count() -> int: ## The number of monster spawns. func monster_count() -> int: return monster_spawns.size() + +## True when this floor is the last floor of the run. +func is_final_floor() -> bool: + return config != null and floor >= Descent.floor_count(config) diff --git a/scripts/input/controls.gd b/scripts/input/controls.gd index 6f93cab..057cccf 100644 --- a/scripts/input/controls.gd +++ b/scripts/input/controls.gd @@ -55,12 +55,12 @@ static func label_for(p_action: StringName, p_gamepad: bool) -> String: return "Start" if p_gamepad else "Esc" return "" -## A two-line hint for the menu, tailored to the active device. +## A 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. +## A 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" + return "Move: left stick or d-pad Attack: A or R shoulder\nOpen doors: walk in with a key Reach the exit to descend\nNew run: Y Pause: Start" + return "Move: WASD or arrows Attack: Space, J, or click\nOpen doors: walk in with a key Reach the exit to descend\nNew run: N Pause: Esc" diff --git a/scripts/main.gd b/scripts/main.gd index 9c87a13..33126f6 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -31,9 +31,13 @@ var monster_index := 0 var _ended := false var _beacon_phase := 0.0 var _beacon: Sprite2D = null +var _transition_layer: CanvasLayer = null func _ready() -> void: _wire_signals() + _transition_layer = CanvasLayer.new() + _transition_layer.layer = 100 + add_child(_transition_layer) get_tree().paused = true menu_overlay.show_menu(false) @@ -66,7 +70,7 @@ func _physics_process(p_delta: float) -> void: _collect_pickups() _pulse_beacon(p_delta) if not _ended and player.grid_pos == run.exit_pos: - _on_victory() + _on_floor_exit() func _on_start_requested(p_seed_text: String) -> void: var text := p_seed_text.strip_edges() @@ -93,7 +97,14 @@ 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) + player.coins = 0 + player.shards = 0 + _start_floor(1) + +## Starts the given floor of the current run. Floors start at 1. +func _start_floor(p_floor: int) -> void: + var floor_seed := Descent.floor_seed(run_seed, p_floor) + run = DungeonGenerator.new().generate(biome, floor_seed, p_floor, run_seed) _clear_world() occupancy.clear() @@ -105,6 +116,7 @@ func _start_run(p_seed_value: int) -> void: "health": biome.starting_health, "damage": biome.player_damage, }) + player.keys_held = 0 player.setup(stats, run.start_pos, dungeon_view, occupancy) occupancy[run.start_pos] = player player.can_enter = _hero_can_enter @@ -123,19 +135,29 @@ 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) + hud.set_run( + biome.display_name, + SeededRng.encode_seed(run_seed), + run.depth, + p_floor, + Descent.floor_count(biome), + ) 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 = p_floor + RunState.max_floors = Descent.floor_count(biome) + if p_floor == 1: + RunState.floors_cleared = 0 + RunState.started_at = Time.get_ticks_msec() / 1000.0 RunState.status = RunState.RunStatus.ACTIVE - RunState.started_at = Time.get_ticks_msec() / 1000.0 menu_overlay.hide_menu() result_overlay.hide_result() 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) @@ -191,7 +213,7 @@ func _on_monster_attack(p_damage: int) -> void: func _on_monster_died(p_monster: MonsterActor) -> void: occupancy.erase(p_monster.grid_pos) - var drop_rng := SeededRng.new(run_seed ^ (monster_index * 0x9E37)) + var drop_rng := SeededRng.new(run_seed ^ (run.floor * 0x10000) ^ (monster_index * 0x9E37)) var drops := MonsterSpecs.roll_drops(p_monster.spec, drop_rng) for drop in drops: _spawn_pickup(drop.item, drop.count, p_monster.grid_pos) @@ -208,22 +230,48 @@ func _on_player_died() -> void: SeededRng.encode_seed(run_seed), run.depth, player.coins, - RunState.elapsed() + RunState.elapsed(), + RunState.floors_cleared, + RunState.max_floors ) get_tree().paused = true +## Called when the hero reaches a floor exit. +## The run ends only when the hero clears the final floor. +func _on_floor_exit() -> void: + if _ended: + return + if run.is_final_floor(): + _on_victory() + else: + _descend() + +## Moves the hero down to the next floor of the same run. +func _descend() -> void: + RunState.floors_cleared = run.floor + var next_floor := run.floor + 1 + TransitionOverlay.play( + _transition_layer, + "Floor cleared", + "Descending to floor %d of %d" % [next_floor, RunState.max_floors] + ) + _start_floor(next_floor) + func _on_victory() -> void: if _ended: return _ended = true RunState.status = RunState.RunStatus.WON + RunState.floors_cleared = RunState.max_floors RunState.finished_at = Time.get_ticks_msec() / 1000.0 result_overlay.show_result( true, SeededRng.encode_seed(run_seed), run.depth, player.coins, - RunState.elapsed() + RunState.elapsed(), + RunState.floors_cleared, + RunState.max_floors ) get_tree().paused = true diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 26a9113..5d2be2d 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -6,6 +6,7 @@ extends Control ## and a minimap with the hero position. var biome_label: Label = null +var floor_label: Label = null var seed_label: Label = null var depth_label: Label = null var hp_fill: ColorRect = null @@ -27,8 +28,15 @@ 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_biome: String, + p_seed: String, + p_depth: int, + p_floor: int = 1, + p_max_floors: int = 1 +) -> void: biome_label.text = "Biome: " + p_biome + floor_label.text = "Floor %d / %d" % [p_floor, p_max_floors] seed_label.text = "Seed: " + p_seed depth_label.text = "Exit at depth " + str(p_depth) @@ -71,9 +79,11 @@ func _build_top_panel() -> void: box.add_theme_constant_override("separation", 2) panel.add_child(box) biome_label = _label("", 15) + floor_label = _label("", 15) seed_label = _label("", 15) depth_label = _label("", 15) box.add_child(biome_label) + box.add_child(floor_label) box.add_child(seed_label) box.add_child(depth_label) add_child(panel) diff --git a/scripts/ui/result_overlay.gd b/scripts/ui/result_overlay.gd index b6417e5..35d2177 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_depth: int, + p_coins: int, + p_time: float, + p_floors_cleared: int = 0, + p_max_floors: int = 1 +) -> 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 / %d\nDepth: %d\nCoins: %d\nTime: %d:%02d" % [ + p_seed, p_floors_cleared, p_max_floors, p_depth, p_coins, minutes, seconds, ] _same_seed_button.visible = p_won _new_button.grab_focus() diff --git a/scripts/ui/transition_overlay.gd b/scripts/ui/transition_overlay.gd new file mode 100644 index 0000000..1d88d2d --- /dev/null +++ b/scripts/ui/transition_overlay.gd @@ -0,0 +1,73 @@ +class_name TransitionOverlay +extends Control +## A brief full-screen message for floor changes. +## +## The overlay fades in, holds, and fades out on its own. The caller only +## adds it to the tree; it frees itself when the tween finishes. + +const FADE_IN := 0.18 +const HOLD := 0.7 +const FADE_OUT := 0.4 + +## Shows a message and returns. The overlay frees itself. +static func play(p_parent: Node, p_title: String, p_subtitle: String) -> void: + var overlay := TransitionOverlay.new() + p_parent.add_child(overlay) + overlay._show(p_title, p_subtitle) + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + mouse_filter = Control.MOUSE_FILTER_IGNORE + _build() + +func _show(p_title: String, p_subtitle: String) -> void: + var dim: ColorRect = $Dim + var title: Label = $Center/Box/Title + var subtitle: Label = $Center/Box/Subtitle + title.text = p_title + subtitle.text = p_subtitle + dim.modulate.a = 0.0 + title.modulate.a = 0.0 + subtitle.modulate.a = 0.0 + + var tween := create_tween() + tween.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS) + tween.tween_property(dim, "modulate:a", 0.6, FADE_IN) + tween.parallel().tween_property(title, "modulate:a", 1.0, FADE_IN) + tween.parallel().tween_property(subtitle, "modulate:a", 1.0, FADE_IN) + tween.tween_interval(HOLD) + tween.tween_property(dim, "modulate:a", 0.0, FADE_OUT) + tween.parallel().tween_property(title, "modulate:a", 0.0, FADE_OUT) + tween.parallel().tween_property(subtitle, "modulate:a", 0.0, FADE_OUT) + tween.tween_callback(queue_free) + +func _build() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + + var dim := ColorRect.new() + dim.name = "Dim" + dim.color = Color(0.02, 0.02, 0.05, 0.6) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(dim) + + var center := CenterContainer.new() + center.name = "Center" + center.set_anchors_preset(Control.PRESET_FULL_RECT) + var box := VBoxContainer.new() + box.name = "Box" + box.add_theme_constant_override("separation", 8) + + var title := Label.new() + title.name = "Title" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 42) + + var subtitle := Label.new() + subtitle.name = "Subtitle" + subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + subtitle.add_theme_font_size_override("font_size", 18) + + box.add_child(title) + box.add_child(subtitle) + center.add_child(box) + add_child(center) diff --git a/scripts/ui/transition_overlay.gd.uid b/scripts/ui/transition_overlay.gd.uid new file mode 100644 index 0000000..db4c69f --- /dev/null +++ b/scripts/ui/transition_overlay.gd.uid @@ -0,0 +1 @@ +uid://dob3ltbmk6l1u diff --git a/tests/integration/test_descent_run.gd b/tests/integration/test_descent_run.gd new file mode 100644 index 0000000..dc24321 --- /dev/null +++ b/tests/integration/test_descent_run.gd @@ -0,0 +1,52 @@ +extends GutTest +## End-to-end descent: every floor of a run stays solvable, and each +## floor of one run differs from the others. + +func test_every_floor_of_every_biome_is_solvable() -> void: + var generator := DungeonGenerator.new() + for biome in Biomes.all(): + for run_seed in [11, 22, 33, 44, 55]: + for floor in range(1, biome.max_floors + 1): + var floor_seed := Descent.floor_seed(run_seed, floor) + var result := generator.generate(biome, floor_seed, floor, run_seed) + assert_true( + result.solvable, + "%s seed %d floor %d is not solvable" % [biome.id, run_seed, floor] + ) + assert_eq(result.floor, floor) + +func test_floors_of_one_run_produce_different_maps() -> void: + var biome := Biomes.ember_stronghold() + var generator := DungeonGenerator.new() + var run_seed := 606 + var first := generator.generate(biome, Descent.floor_seed(run_seed, 1), 1, run_seed) + var second := generator.generate(biome, Descent.floor_seed(run_seed, 2), 2, run_seed) + var differences := 0 + for y in first.map.height: + for x in first.map.width: + if first.map.get_tile(x, y) != second.map.get_tile(x, y): + differences += 1 + assert_gt(differences, 100) + assert_ne(first.exit_pos, second.exit_pos) + +func test_exit_stays_reachable_on_every_floor() -> void: + var generator := DungeonGenerator.new() + for biome in Biomes.all(): + var run_seed := 707 + for floor in range(1, biome.max_floors + 1): + var result := generator.generate(biome, Descent.floor_seed(run_seed, floor), floor, run_seed) + assert_true( + Pathfinding.reaches(result.map, result.start_pos, result.exit_pos, true), + "%s floor %d" % [biome.id, floor] + ) + +func test_replay_of_a_run_repeats_every_floor() -> void: + var biome := Biomes.crypt() + var generator := DungeonGenerator.new() + var run_seed := 808 + for floor in range(1, biome.max_floors + 1): + var a := generator.generate(biome, Descent.floor_seed(run_seed, floor), floor, run_seed) + var b := generator.generate(biome, Descent.floor_seed(run_seed, floor), floor, run_seed) + assert_eq(a.map._cells, b.map._cells) + assert_eq(a.doors.size(), b.doors.size()) + assert_eq(a.exit_pos, b.exit_pos) diff --git a/tests/integration/test_descent_run.gd.uid b/tests/integration/test_descent_run.gd.uid new file mode 100644 index 0000000..299687e --- /dev/null +++ b/tests/integration/test_descent_run.gd.uid @@ -0,0 +1 @@ +uid://bdkjm117b1myh diff --git a/tests/unit/test_descent.gd b/tests/unit/test_descent.gd new file mode 100644 index 0000000..a3947d9 --- /dev/null +++ b/tests/unit/test_descent.gd @@ -0,0 +1,63 @@ +extends GutTest +## The descent seed chain: deterministic, distinct, and bound to the +## biome floor count. + +func test_floor_one_uses_the_run_seed() -> void: + for seed in [0, 1, 12345, 16777215]: + assert_eq(Descent.floor_seed(seed, 1), seed) + +func test_floor_seeds_are_deterministic() -> void: + for run_seed in [7, 99, 424242]: + var first := Descent.floor_seed(run_seed, 3) + var second := Descent.floor_seed(run_seed, 3) + assert_eq(first, second) + +func test_floor_seeds_differ_within_a_run() -> void: + var run_seed := 54321 + var seen := {} + for floor in range(1, 9): + var seed := Descent.floor_seed(run_seed, floor) + assert_false(seen.has(seed), "floor %d repeats a seed" % floor) + seen[seed] = true + +func test_floor_seeds_stay_in_seed_range() -> void: + for run_seed in [1, 2, 3, 65536, 16777215]: + for floor in range(1, 9): + var seed := Descent.floor_seed(run_seed, floor) + assert_between(seed, 0, SeededRng.SEED_MASK) + +func test_generator_reports_the_floor_and_run_seed() -> void: + var biome := Biomes.crypt() + var base := 31415 + for floor in [1, 2, 3]: + var result := DungeonGenerator.new().generate( + biome, Descent.floor_seed(base, floor), floor, base + ) + assert_eq(result.floor, floor) + assert_eq(result.run_seed, base) + assert_eq(result.seed_value, Descent.floor_seed(base, floor)) + +func test_single_floor_calls_keep_the_old_defaults() -> void: + var biome := Biomes.crypt() + var result := DungeonGenerator.new().generate(biome, 999) + assert_eq(result.floor, 1) + assert_eq(result.run_seed, 999) + assert_eq(result.seed_value, 999) + +func test_every_biome_supports_more_than_one_floor() -> void: + for biome in Biomes.all(): + assert_gte(biome.max_floors, 2, biome.id) + +func test_max_floors_validates_positive() -> void: + var biome := Biomes.crypt() + biome.max_floors = 0 + assert_false(biome.is_valid()) + assert_true(biome.validate().has("max floors must be at least 1")) + +func test_is_final_floor_matches_floor_count() -> void: + var biome := Biomes.ember_stronghold() + for floor in range(1, biome.max_floors + 1): + var result := DungeonGenerator.new().generate( + biome, Descent.floor_seed(77, floor), floor, 77 + ) + assert_eq(result.is_final_floor(), floor == biome.max_floors) diff --git a/tests/unit/test_descent.gd.uid b/tests/unit/test_descent.gd.uid new file mode 100644 index 0000000..7e90cfd --- /dev/null +++ b/tests/unit/test_descent.gd.uid @@ -0,0 +1 @@ +uid://m5dj0t44wprc diff --git a/tools/smoke.gd b/tools/smoke.gd index 3b50254..f42a5a4 100644 --- a/tools/smoke.gd +++ b/tools/smoke.gd @@ -1,9 +1,9 @@ extends SceneTree ## Headless smoke test for CI. ## -## Loads the main scene, starts a fixed-seed run, lets the engine run a -## few frames, then verifies the world is live and solvable. Exits with -## code 0 on success and code 1 on failure. +## Loads the main scene, starts a fixed-seed run, clears every floor, +## and verifies the world stays live and solvable. The final floor +## awards victory. Exits with code 0 on success and code 1 on failure. var _main: Main = null var _failed := false @@ -34,20 +34,71 @@ func _run() -> void: if _failed: quit(1) return - _verify() + _verify_floor(1) + if _failed: + quit(1) + return + _verify_input_bindings() + if _failed: + quit(1) + return + + var max_floors := Descent.floor_count(_main.biome) + for floor in range(2, max_floors + 1): + print("[smoke] descending to floor %d" % floor) + _step_onto_exit() + for i in 8: + await physics_frame + if _failed: + quit(1) + return + _verify_floor(floor) + if _failed: + quit(1) + return + + print("[smoke] clearing the final floor") + _step_onto_exit() + for i in 8: + await physics_frame + + if _failed: + quit(1) + return + + if not _main._ended: + _fail("victory did not trigger on the final floor") + elif RunState.status != RunState.RunStatus.WON: + _fail("run did not end in victory") + elif RunState.floors_cleared != RunState.max_floors: + _fail("floors cleared does not match the run length") -func _verify() -> void: + if _failed: + print("[smoke] FAILED") + quit(1) + else: + print("Smoke test passed: seed 12345 cleared %d floors." % max_floors) + quit(0) + +func _step_onto_exit() -> void: + _main.player.grid_pos = _main.run.exit_pos + _main.player.position = _main.dungeon_view.tile_to_world(_main.run.exit_pos) + +func _verify_floor(p_expected_floor: int) -> void: if _main.run == null: _fail("no run was generated") elif _main.player == null: _fail("player was not spawned") + elif _main.run.floor != p_expected_floor: + _fail("expected floor %d, got %d" % [p_expected_floor, _main.run.floor]) elif _main.player.grid_pos != _main.run.start_pos: _fail("player is not at the start tile") elif not Pathfinding.reaches(_main.run.map, _main.run.start_pos, _main.run.exit_pos, true): _fail("exit is not reachable from the start") elif not _main.run.solvable: _fail("dungeon is not solvable") - _verify_input_bindings() + elif p_expected_floor == 1 and _main.player.coins != 0: + _fail("a fresh run must start with zero coins") func _verify_input_bindings() -> void: for action in Controls.ACTIONS: @@ -61,13 +112,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)