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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
*.md text eol=lf
*.sh text eol=lf
*.ps1 text eol=lf
*.yml text eol=lf
*.svg text eol=lf
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 5
labels:
- "ci"
- "dependencies"
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,22 @@ on:
push:
branches: [main]
pull_request:
workflow_dispatch:

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

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out the repository
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Install Godot 4.6
uses: chickensoft-games/setup-godot@v2
Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Changelog

All notable changes are listed here. The format follows Keep a Changelog.
Versions follow Semantic Versioning.

## [Unreleased]

### Added

- A fourth biome: the Sunken Ruins.
The biome uses wide winding corridors and a deep teal palette.
- A new monster: the Riptide.
The Riptide is a fast stalker that drops upgrade items.
- Two permanent upgrade items.
A whetstone raises attack damage by 2.
A relic raises max health by 10 and heals part of the gap.
- An item registry with ids, names, categories, and upgrade math.
- A HUD readout that shows collected stat bonuses.
- A unit and integration suite for items and the new biome.

### Changed

- Monster drop tables can now carry optional extra entries.
- The hero tracks permanent damage and max-health bonuses per run.
- The headless smoke test also verifies the Sunken Ruins
and the upgrade effects.

### Fixed

- None.

## [1.0.0] - 2026-08-03

### Added

- Seeded dungeon generation with a guaranteed reachable exit.
- Three biomes with different generation rules.
- Rooms, corridors, locked doors, keys, and monsters.
- Procedural pixel art generated from code.
- A minimap, a health bar, and run summary overlays.
- A GUT test suite and headless CI pipeline.
50 changes: 34 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,23 @@ The same seed always builds the same dungeon.

- A new map for every run, driven by a seed.
- Rooms, corridors, locked doors, and keys.
- Three biomes with different generation rules.
- Four biomes with different generation rules.
- Monsters with simple combat and balanced drops.
- Permanent upgrade items dropped by monsters.
- Procedural pixel art with no bundled image files.
- A minimap, a health bar, and run summary overlays.
- Deterministic generation for replayable runs.

## 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 a fourth biome and permanent upgrades.
The Sunken Ruins flood the map with wide winding corridors.
Riptides, a new fast monster, patrol the flooded halls.

Monsters rarely drop upgrade items.
A whetstone raises your attack damage by 2.
A relic raises your max health by 10 and heals you.
The HUD shows the bonuses you have collected.

## Requirements

Expand Down Expand Up @@ -90,10 +95,16 @@ 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.

Items use a small registry.
Each item has an id, a name, and a category.
Loot tables roll a weighted entry.
Upgrades mutate the hero stats through one pure function.
The same seed always produces the same drops.

## Project layout

- `scripts/dungeon` holds the generator and map logic.
- `scripts/combat` holds stats, monsters, and loot tables.
- `scripts/combat` holds stats, monsters, items, and loot tables.
- `scripts/world` renders tiles and builds the minimap.
- `scripts/actors` holds the hero, monsters, and pickups.
- `scripts/ui` builds the HUD and overlays.
Expand All @@ -107,25 +118,32 @@ A seed always produces the same map.
Doors never block the exit permanently.
Every key sits on the reachable side of its door.
Monsters never cross a locked door.
Upgrade items never change the generated map.

## Evaluation evidence

The suite has 56 tests.
It covers generation, biomes, combat, drops, and pathfinding.
All 56 tests pass in a headless run.
A smoke test loads the game and spawns a fixed-seed run.
The suite has 70 tests.
It covers generation, biomes, combat, drops, pathfinding,
and the item registry.
All 70 tests pass in a headless run.
A smoke test loads the game, spawns a fixed-seed run,
and checks the Sunken Ruins and the upgrade effects.

## Roadmap

- Add multi-floor descent and a depth counter.
- Add ranged monsters and projectiles.
- Add sound and music.
- Add more biomes and items.
Complete:

- More biomes and items.
- The Sunken Ruins biome and the Riptide monster.
- Whetstone and relic permanent upgrades.

Next:

- Add controller support.

## Limitations

The demo has three biomes.
The demo has four biomes.
Each run is a single floor.
All monsters use melee attacks.
The game has no audio yet.
Expand Down
23 changes: 22 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ Each biome picks a carving style.
- Winding: a path of short, jittered runs.
- Straight: a path that favors one axis at a time.

Four biomes share these three styles.
The crypt uses elbows, the drowned forest and the sunken ruins
use winding paths, and the ember stronghold uses straight halls.
Each biome also sets its own room sizes, door counts, monster
pressure, and palette.

All carvers keep every carved cell orthogonally adjacent to the path.
This rule prevents floating single-cell islands.

Expand Down Expand Up @@ -83,6 +89,20 @@ Monsters chase, stalk, or hold ground according to their spec.
Each monster rolls loot from a weighted `DropTable`.
Coins drop often, shards sometimes, potions rarely.

## Items

Every collectible lives in a small registry.
`ItemSpec` holds the id, name, description, and category.
The `Items` class is the single source of truth for ids.
Treasure, consumables, and upgrades share the same table.

Upgrade items use one pure function.
`Items.apply_upgrade` mutates a `CombatStats` block and returns the change.
A whetstone adds 2 to damage.
A relic adds 10 to max health and heals toward the new maximum.
The hero tracks the bonuses so the HUD can show them.
The registry keeps loot logic testable and consistent.

## Scene flow

The `Main` scene owns the game loop.
Expand All @@ -98,7 +118,8 @@ The biome palette recolors the tiles at run time.
## 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 item registry.
Integration tests run many seeds across all biomes.
Every generated dungeon must be solvable.

Expand Down
13 changes: 13 additions & 0 deletions scripts/actors/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ signal hp_changed(current: int, max: int)
signal coins_changed(count: int)
signal shards_changed(count: int)
signal keys_changed(count: int)
signal upgrades_changed(bonus_damage: int, bonus_max_health: int)
signal attacked(origin: Vector2i, facing: Vector2i, range: float, damage: int)
signal moved(grid: Vector2i)
signal died
Expand All @@ -26,6 +27,10 @@ var facing: Vector2i = Vector2i.DOWN
var coins := 0
var shards := 0
var keys_held := 0
## Permanent damage bonus from picked-up upgrades.
var damage_bonus := 0
## Permanent max-health bonus from picked-up upgrades.
var max_health_bonus := 0

var view: DungeonView = null
var occupancy: Dictionary = {}
Expand Down Expand Up @@ -137,6 +142,13 @@ func apply_pickup(p_item: StringName, p_count: int) -> void:
&"key":
keys_held += p_count
keys_changed.emit(keys_held)
&"whetstone", &"relic":
for i in p_count:
var change := Items.apply_upgrade(p_item, stats)
damage_bonus += int(change.get("damage", 0))
max_health_bonus += int(change.get("max_health", 0))
hp_changed.emit(stats.health, stats.max_health)
upgrades_changed.emit(damage_bonus, max_health_bonus)

## Consumes one key, used when the hero opens a locked door.
func spend_key() -> void:
Expand All @@ -153,6 +165,7 @@ func emit_hud() -> void:
coins_changed.emit(coins)
shards_changed.emit(shards)
keys_changed.emit(keys_held)
upgrades_changed.emit(damage_bonus, max_health_bonus)

func _flash() -> void:
var tween := create_tween()
Expand Down
20 changes: 20 additions & 0 deletions scripts/combat/item_spec.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class_name ItemSpec
extends RefCounted
## Static definition of a collectible item.
##
## An item has an id, a display name, a description, a category, and the
## art key used to draw its sprite. The registry is the single source of
## truth for item ids, so the player, the loot tables, and the renderer
## all agree on what exists.

enum Category {
TREASURE,
CONSUMABLE,
UPGRADE,
}

var id: StringName = &""
var display_name: String = ""
var description: String = ""
var category: Category = Category.TREASURE
var sprite_key: StringName = &""
1 change: 1 addition & 0 deletions scripts/combat/item_spec.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://csw8i2yhjhewt
100 changes: 100 additions & 0 deletions scripts/combat/items.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
class_name Items
extends RefCounted
## Registry of every item in the game.
##
## Treasure items add to the score. Consumables heal the hero. Upgrades
## change the hero stats for the rest of the run. The registry is the
## single source of truth for ids, names, categories, and upgrade math,
## which keeps loot logic testable and consistent.

## Returns every item in a stable order.
static func all() -> Array[ItemSpec]:
return [coin(), shard(), potion(), key(), whetstone(), relic()]

## Returns the item with the given id, or coins for an unknown id.
static func by_id(p_id: StringName) -> ItemSpec:
for item in all():
if item.id == p_id:
return item
return coin()

## True when the registry knows the given item id.
static func has(p_id: StringName) -> bool:
return by_id(p_id).id == p_id

## True when the item is a permanent upgrade.
static func is_upgrade(p_id: StringName) -> bool:
return by_id(p_id).category == ItemSpec.Category.UPGRADE

## Applies an upgrade to the given stats.
##
## Mutates the stats and returns the change as a dictionary. Optional
## keys are "damage", "max_health", and "health". Unknown items change
## nothing and return an empty dictionary.
static func apply_upgrade(p_id: StringName, p_stats: CombatStats) -> Dictionary:
match p_id:
&"whetstone":
p_stats.damage += 2
return { "damage": 2 }
&"relic":
var old_max := p_stats.max_health
p_stats.max_health += 10
var health_gain := mini(10, p_stats.max_health - p_stats.health)
p_stats.health += health_gain
return { "max_health": p_stats.max_health - old_max, "health": health_gain }
_:
return {}

static func coin() -> ItemSpec:
var item := ItemSpec.new()
item.id = &"coin"
item.display_name = "Coin"
item.description = "Shiny treasure from a fallen monster."
item.category = ItemSpec.Category.TREASURE
item.sprite_key = &"coin"
return item

static func shard() -> ItemSpec:
var item := ItemSpec.new()
item.id = &"shard"
item.display_name = "Shard"
item.description = "A fragment of a shattered relic."
item.category = ItemSpec.Category.TREASURE
item.sprite_key = &"shard"
return item

static func potion() -> ItemSpec:
var item := ItemSpec.new()
item.id = &"potion"
item.display_name = "Potion"
item.description = "Restores 25 health when picked up."
item.category = ItemSpec.Category.CONSUMABLE
item.sprite_key = &"potion"
return item

static func key() -> ItemSpec:
var item := ItemSpec.new()
item.id = &"key"
item.display_name = "Key"
item.description = "Opens one locked door."
item.category = ItemSpec.Category.CONSUMABLE
item.sprite_key = &"key"
return item

static func whetstone() -> ItemSpec:
var item := ItemSpec.new()
item.id = &"whetstone"
item.display_name = "Whetstone"
item.description = "Raises attack damage by 2 for this run."
item.category = ItemSpec.Category.UPGRADE
item.sprite_key = &"whetstone"
return item

static func relic() -> ItemSpec:
var item := ItemSpec.new()
item.id = &"relic"
item.display_name = "Relic"
item.description = "Raises max health by 10 and heals for this run."
item.category = ItemSpec.Category.UPGRADE
item.sprite_key = &"relic"
return item
1 change: 1 addition & 0 deletions scripts/combat/items.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://eic26tscy2o8
Loading