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..bd31f70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Cache the GUT addon - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: addons/gut key: gut-${{ hashFiles('tools/gut.version.json') }} @@ -41,3 +41,12 @@ jobs: - name: Run the headless smoke test run: godot --headless --path . -s tools/smoke.gd + + - name: Confirm the working tree stays clean + run: | + status=$(git status --porcelain) + if [ -n "$status" ]; then + echo "$status" + echo "Import generated untracked files. Track them or update .gitignore." + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index ec17257..fc422c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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 + +- Ranged combat with projectiles. +- Three new ranged monsters across the biomes. +- Line-of-sight checks so monsters cannot shoot through walls. +- Kiting behaviour that keeps ranged monsters at a distance. +- Unit tests for aim, blocking, and line of sight. +- Integration tests for firing, impact, and wall stops. + ## [0.2.0] - 2026-08-03 Added diff --git a/README.md b/README.md index 9c733e9..c1827fb 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. +A seeded dungeon crawler built with Godot. +Every run forges a new dungeon that you must conquer. ``` ####.D.############ @@ -13,20 +13,29 @@ Every run builds a new dungeon that you can explore and finish. ##########.D..###### ``` -## What it is +## The dungeon -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. +Dungeonwright draws a new map for every run. +You explore rooms, corridors, and locked halls. +You collect keys, open doors, and reach the exit. +The same seed always forges the same dungeon. + +## What this release adds + +This release adds ranged combat. +Three new monsters attack from a distance. +They fire bolts, spit, and burning embers across rooms. +Close the gap or break line of sight to survive. +Dodgeable shots add tension to every corridor fight. ## Features - A new map for every run, driven by a seed. - Rooms, corridors, locked doors, and keys. -- Three biomes with different generation rules. -- Monsters with simple combat and balanced drops. -- Procedural pixel art with no bundled image files. +- Three biomes with distinct generation rules. +- Melee monsters and three new ranged monsters. +- Projectiles that stop at walls and locked doors. +- Procedural pixel art with no bundled images. - A minimap, a health bar, and run summary overlays. - Deterministic generation for replayable runs. - Full gamepad support with analog movement. @@ -35,17 +44,9 @@ The same seed always builds the same dungeon. 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 walk, fight, dodge, 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. - ## Requirements - Godot 4.6 or newer. @@ -96,7 +97,7 @@ The tests run from the copy in `addons/gut`. ## How it works -The generator has a fixed pipeline. +The generator follows a fixed pipeline. It places rooms, connects them with corridors, and carves the map. It picks the farthest room as the exit. It places doors on corridors and puts each key on the safe side. @@ -106,13 +107,22 @@ 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. +Ranged monsters keep their distance. +They fire only when they can see the hero. +A Bresenham walk checks every tile between them. +Walls and locked doors stop each projectile. + +The projectile is a plain node. +It flies in a straight line toward its target. +It despawns after its range or on impact. + ## Project layout - `scripts/dungeon` holds the generator and map logic. - `scripts/combat` holds stats, monsters, and loot tables. -- `scripts/input` holds the controls helper. +- `scripts/actors` holds the hero, monsters, and projectiles. - `scripts/world` renders tiles and builds the minimap. -- `scripts/actors` holds the hero, monsters, and pickups. +- `scripts/input` holds the controls helper. - `scripts/ui` builds the HUD and overlays. - `scenes` holds the scene tree. - `tests` holds the GUT suite. @@ -124,23 +134,30 @@ 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. +Projectiles never cross a wall or 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 85 tests. +It covers generation, biomes, combat, drops, pathfinding, input, +and ranged combat. +All 85 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 also checks gamepad bindings and ranged rules. ## Roadmap Done in this release: +- Ranged monsters and projectiles. + +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. @@ -148,7 +165,7 @@ Next up: The demo has three biomes. Each run is a single floor. -All monsters use melee attacks. +Projectiles fly straight and never home in. The game has no audio yet. ## License diff --git a/docs/architecture.md b/docs/architecture.md index df3db0f..848729b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,9 +80,25 @@ Attack range and facing arcs use tile math, not physics. The player attacks in a facing arc. Monsters chase, stalk, or hold ground according to their spec. +Ranged monsters fire projectiles and keep their distance. Each monster rolls loot from a weighted `DropTable`. Coins drop often, shards sometimes, potions rarely. +### Ranged combat + +Ranged monsters follow a separate rule set in `MonsterSpec`. +They carry a minimum range, a fire range, and a bolt speed. +Each biome hosts one ranged monster type. + +The monster aims only when it has line of sight. +A Bresenham walk in `Projectile.los_clear` checks every tile on the +segment. A wall or a locked door blocks the shot. + +A `Projectile` node flies in a straight line. +It stops at walls and locked doors, and it despawns after its range. +The controller spawns bolts from a dedicated scene. +The pure rules live in static helpers, so tests run without a scene. + ## Scene flow The `Main` scene owns the game loop. diff --git a/scenes/actors/projectile.tscn b/scenes/actors/projectile.tscn new file mode 100644 index 0000000..684f9a9 --- /dev/null +++ b/scenes/actors/projectile.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/actors/projectile.gd" id="1_projectile"] + +[node name="Projectile" type="Node2D"] +script = ExtResource("1_projectile") diff --git a/scenes/main.tscn b/scenes/main.tscn index 6b839ea..72438ef 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -24,6 +24,8 @@ script = ExtResource("2_view") [node name="Effects" type="Node2D" parent="World"] +[node name="Projectiles" type="Node2D" parent="World"] + [node name="Player" parent="World" instance=ExtResource("3_player")] [node name="Camera" type="Camera2D" parent="."] diff --git a/scripts/actors/monster_actor.gd b/scripts/actors/monster_actor.gd index 8ceebe2..2d6f226 100644 --- a/scripts/actors/monster_actor.gd +++ b/scripts/actors/monster_actor.gd @@ -3,11 +3,13 @@ extends Node2D ## A living monster. ## ## Each monster reads its behaviour from a MonsterSpec. Chasers walk -## toward the hero, stalkers are fast and aggressive, and sentries hold -## their ground and lash out at anyone who comes close. Movement follows -## a short flood-fill path so monsters rarely get stuck on walls. +## toward the hero, stalkers are fast and aggressive, sentries hold +## their ground, and ranged monsters fire bolts while keeping distance. +## Movement follows a short flood-fill path so monsters rarely get +## stuck on walls. signal attack_player(damage: int) +signal shoot_requested(origin: Vector2i, target: Vector2i, spec: MonsterSpec) signal hp_changed(current: int, max: int) signal died(monster) @@ -64,30 +66,16 @@ func _physics_process(p_delta: float) -> void: _repath_timer = maxf(0.0, _repath_timer - p_delta) if target == null: return - - var distance_sq := Vector2(grid_pos).distance_squared_to(Vector2(target.grid_pos)) - if Combat.within_attack_range(distance_sq, stats.attack_range): - _try_attack() - return - - if spec.ai == MonsterSpec.AI.sentry: - return - if distance_sq > spec.aggro_range * spec.aggro_range: + if _moving: + _advance_movement(p_delta) + _sprite.position.y = -2.0 return - if _repath_timer <= 0.0: - _repath_timer = REPATH_INTERVAL - _path = Pathfinding.find_path(view.map, grid_pos, target.grid_pos, false) - if _moving: - _progress += p_delta * stats.speed - if _progress >= 1.0: - grid_pos = _to - position = view.tile_to_world(grid_pos) - _moving = false - occupancy.erase(_from) - occupancy[grid_pos] = self + var distance_sq := Vector2(grid_pos).distance_squared_to(Vector2(target.grid_pos)) + if spec.ranged: + _ranged_think(distance_sq) else: - _step_along_path() + _melee_think(distance_sq) _sprite.position.y = -2.0 if _moving else 0.0 ## Applies damage. Returns true when the monster died. @@ -111,15 +99,83 @@ func _try_attack() -> void: attack_player.emit(stats.damage) _lunge() +## Melee behaviour: attack in range, hold ground as a sentry, else chase. +func _melee_think(p_distance_sq: float) -> void: + if Combat.within_attack_range(p_distance_sq, stats.attack_range): + _try_attack() + return + if spec.ai == MonsterSpec.AI.sentry: + return + _approach_target(p_distance_sq) + +## Ranged behaviour: flee a close hero, fire with line of sight, +## and advance until the hero is in range. +func _ranged_think(p_distance_sq: float) -> void: + if Combat.within_attack_range(p_distance_sq, spec.min_range): + _flee_from_target() + return + var in_fire_range := p_distance_sq <= spec.projectile_range * spec.projectile_range + var has_los := Projectile.los_clear(view.map, grid_pos, target.grid_pos) + if in_fire_range and has_los: + _try_fire() + return + _approach_target(p_distance_sq) + +## Walks toward the hero along a short flood-fill path. +func _approach_target(p_distance_sq: float) -> void: + if p_distance_sq > spec.aggro_range * spec.aggro_range: + return + if _repath_timer <= 0.0: + _repath_timer = REPATH_INTERVAL + _path = Pathfinding.find_path(view.map, grid_pos, target.grid_pos, false) + _step_along_path() + +## Steps one cell away from the hero, choosing the open neighbour that +## increases the distance the most. +func _flee_from_target() -> void: + var best := Vector2i(-1, -1) + var best_distance := -1.0 + for offset: Vector2i in Pathfinding.ORTHO: + var candidate := grid_pos + offset + if not _can_step(candidate): + continue + var distance := Vector2(candidate).distance_squared_to(Vector2(target.grid_pos)) + if distance > best_distance: + best_distance = distance + best = candidate + if best == Vector2i(-1, -1): + return + _from = grid_pos + _to = best + _progress = 0.0 + _moving = true + +## Fires a projectile when the attack cooldown is ready. +func _try_fire() -> void: + if _attack_timer > 0.0: + return + _attack_timer = stats.attack_cooldown + shoot_requested.emit(grid_pos, target.grid_pos, spec) + _lunge() + +## Finishes the current tile step and claims the new cell. +func _advance_movement(p_delta: float) -> void: + _progress += p_delta * stats.speed + if _progress < 1.0: + return + grid_pos = _to + position = view.tile_to_world(grid_pos) + _moving = false + occupancy.erase(_from) + occupancy[grid_pos] = self + func _step_along_path() -> void: while not _path.is_empty(): var next_cell := _path[_path.size() - 1] _path.remove_at(_path.size() - 1) if next_cell == grid_pos: continue - if view.map.get_tile_cell(next_cell) == DungeonMap.Tile.DOOR_LOCKED: - return - if occupancy.has(next_cell): + if not _can_step(next_cell): return _from = grid_pos _to = next_cell @@ -127,6 +183,18 @@ func _step_along_path() -> void: _moving = true return +## True when a monster may step onto the cell. +func _can_step(p_cell: Vector2i) -> bool: + if not view.map.in_bounds_cell(p_cell): + return false + if view.map.get_tile_cell(p_cell) == DungeonMap.Tile.DOOR_LOCKED: + return false + if not view.map.is_walkable_cell(p_cell): + return false + if occupancy.has(p_cell): + return false + return true + func _build_sprite() -> void: _sprite = Sprite2D.new() _sprite.texture = TileArt.entity_texture(StringName(spec.sprite_key)) diff --git a/scripts/actors/projectile.gd b/scripts/actors/projectile.gd new file mode 100644 index 0000000..6774b1c --- /dev/null +++ b/scripts/actors/projectile.gd @@ -0,0 +1,99 @@ +class_name Projectile +extends Node2D +## A bolt fired by a ranged monster. +## +## The projectile flies in a straight line toward the hero. It stops at +## walls and locked doors, and it despawns after its range. The static +## helpers hold the core rules so the test suite can check them without +## running the scene tree. + +signal hit_player(damage: int) + +const TILE_SIZE := 16.0 +const HIT_RADIUS := 8.0 + +var damage: int = 1 +var speed: float = 7.0 +var max_range: float = 7.0 +var view: DungeonView = null +var target: Node2D = null + +var _direction := Vector2.RIGHT +var _traveled := 0.0 +var _sprite: Sprite2D = null + +## Arms the projectile from a monster spec and launches it toward a tile. +func setup( + p_spec: MonsterSpec, + p_origin: Vector2i, + p_target_grid: Vector2i, + p_view: DungeonView, + p_target: Node2D +) -> void: + damage = p_spec.projectile_damage + speed = p_spec.projectile_speed + max_range = p_spec.projectile_range + view = p_view + target = p_target + position = view.tile_to_world(p_origin) + _direction = aim(view.tile_to_world(p_origin), view.tile_to_world(p_target_grid)) + _sprite = Sprite2D.new() + _sprite.texture = TileArt.entity_texture(p_spec.projectile_key) + _sprite.centered = true + _sprite.modulate = p_spec.projectile_tint + add_child(_sprite) + +func _physics_process(p_delta: float) -> void: + if view == null or _sprite == null: + return + position += _direction * speed * TILE_SIZE * p_delta + _traveled += speed * p_delta + if _traveled >= max_range: + queue_free() + return + if _hits_target(): + hit_player.emit(damage) + queue_free() + return + if blocks(view.map.get_tile_cell(view.world_to_tile(position))): + queue_free() + +## The unit direction from one world position to another. +static func aim(p_from: Vector2, p_to: Vector2) -> Vector2: + var delta := p_to - p_from + if delta.length_squared() <= 0.0001: + return Vector2.RIGHT + return delta.normalized() + +## True when a tile stops a projectile. +static func blocks(p_tile: int) -> bool: + return p_tile == DungeonMap.Tile.WALL or p_tile == DungeonMap.Tile.DOOR_LOCKED + +## True when no blocking tile lies between the two cells. +## Uses a Bresenham walk, so walls cut the line at the exact cell. +static func los_clear(p_map: DungeonMap, p_from: Vector2i, p_to: Vector2i) -> bool: + var x := p_from.x + var y := p_from.y + var dx := absi(p_to.x - p_from.x) + var dy := -absi(p_to.y - p_from.y) + var step_x := 1 if p_from.x < p_to.x else -1 + var step_y := 1 if p_from.y < p_to.y else -1 + var error := dx + dy + while true: + if x == p_to.x and y == p_to.y: + return true + if blocks(p_map.get_tile_cell(Vector2i(x, y))): + return false + var twice_error := 2 * error + if twice_error >= dy: + error += dy + x += step_x + if twice_error <= dx: + error += dx + y += step_y + return true + +func _hits_target() -> bool: + if target == null or not is_instance_valid(target): + return false + return position.distance_to(target.position) <= HIT_RADIUS diff --git a/scripts/actors/projectile.gd.uid b/scripts/actors/projectile.gd.uid new file mode 100644 index 0000000..f85f36e --- /dev/null +++ b/scripts/actors/projectile.gd.uid @@ -0,0 +1 @@ +uid://beb0jpir07smm diff --git a/scripts/combat/monster_spec.gd b/scripts/combat/monster_spec.gd index 5f43b5f..1afe416 100644 --- a/scripts/combat/monster_spec.gd +++ b/scripts/combat/monster_spec.gd @@ -10,6 +10,7 @@ const AI := { "chaser": &"chaser", "stalker": &"stalker", "sentry": &"sentry", + "ranged": &"ranged", } var id: StringName = &"" @@ -20,6 +21,20 @@ var aggro_range: float = 8.0 var stats: CombatStats = null var drop_table: DropTable = null +## Ranged behaviour. Ranged monsters fire projectiles and keep distance. +var ranged: bool = false +var min_range: float = 2.0 +var projectile_range: float = 7.0 +var projectile_speed: float = 7.0 +var projectile_damage: int = 6 +var projectile_key: StringName = &"bolt" +var projectile_tint: Color = Color.WHITE + ## True when this spec is valid for spawning. func is_valid() -> bool: - return id != &"" and stats != null and drop_table != null + if id == &"" or stats == null or drop_table == null: + return false + if not ranged: + return true + return projectile_damage > 0 and projectile_speed > 0.0 \ + and projectile_range >= min_range diff --git a/scripts/combat/monster_specs.gd b/scripts/combat/monster_specs.gd index f39e62d..a07ddb9 100644 --- a/scripts/combat/monster_specs.gd +++ b/scripts/combat/monster_specs.gd @@ -13,6 +13,9 @@ static func all() -> Array[MonsterSpec]: wisp(), shambler(), golem(), + bonecaster(), + spitter(), + slinger(), ] static func by_id(p_id: StringName) -> MonsterSpec: @@ -66,6 +69,51 @@ static func golem() -> MonsterSpec: spec.drop_table = DropTable.from_entries(_entries([4.0, 3.0, 2.0]), null) return spec +static func bonecaster() -> MonsterSpec: + var spec := _base(&"bonecaster", "Bone Caster", MonsterSpec.AI.ranged, "bonecaster", 9.0) + spec.stats = CombatStats.make({ + "max_health": 24, "health": 24, "damage": 9, + "speed": 1.4, "attack_range": 1.5, "attack_cooldown": 1.4, + }) + spec.ranged = true + spec.min_range = 2.5 + spec.projectile_range = 7.0 + spec.projectile_speed = 6.0 + spec.projectile_damage = 9 + spec.projectile_key = &"bolt" + spec.drop_table = DropTable.from_entries(_entries([5.0, 2.0, 1.0]), null) + return spec + +static func spitter() -> MonsterSpec: + var spec := _base(&"spitter", "Root Spitter", MonsterSpec.AI.ranged, "spitter", 8.0) + spec.stats = CombatStats.make({ + "max_health": 16, "health": 16, "damage": 7, + "speed": 2.6, "attack_range": 1.5, "attack_cooldown": 1.1, + }) + spec.ranged = true + spec.min_range = 2.0 + spec.projectile_range = 6.0 + spec.projectile_speed = 7.0 + spec.projectile_damage = 7 + spec.projectile_key = &"spit" + spec.drop_table = DropTable.from_entries(_entries([4.0, 2.0, 0.5]), null) + return spec + +static func slinger() -> MonsterSpec: + var spec := _base(&"slinger", "Cinder Slinger", MonsterSpec.AI.ranged, "slinger", 8.0) + spec.stats = CombatStats.make({ + "max_health": 34, "health": 34, "damage": 11, + "speed": 1.6, "attack_range": 1.5, "attack_cooldown": 1.6, + }) + spec.ranged = true + spec.min_range = 3.0 + spec.projectile_range = 8.0 + spec.projectile_speed = 6.0 + spec.projectile_damage = 11 + spec.projectile_key = &"ember" + spec.drop_table = DropTable.from_entries(_entries([4.0, 2.0, 1.5]), null) + return spec + static func _base( p_id: StringName, p_name: String, diff --git a/scripts/dungeon/biomes.gd b/scripts/dungeon/biomes.gd index 5711f56..5bc66d8 100644 --- a/scripts/dungeon/biomes.gd +++ b/scripts/dungeon/biomes.gd @@ -40,6 +40,7 @@ static func crypt() -> DungeonConfig: config.monster_table = [ { "monster": &"skeleton", "weight": 3.0 }, { "monster": &"crawler", "weight": 1.0 }, + { "monster": &"bonecaster", "weight": 1.5 }, ] config.starting_health = 100 config.player_damage = 12 @@ -67,6 +68,7 @@ static func drowned_forest() -> DungeonConfig: { "monster": &"wisp", "weight": 2.0 }, { "monster": &"shambler", "weight": 2.0 }, { "monster": &"crawler", "weight": 1.0 }, + { "monster": &"spitter", "weight": 1.5 }, ] config.starting_health = 100 config.player_damage = 13 @@ -94,6 +96,7 @@ static func ember_stronghold() -> DungeonConfig: { "monster": &"golem", "weight": 2.0 }, { "monster": &"skeleton", "weight": 2.0 }, { "monster": &"shambler", "weight": 1.0 }, + { "monster": &"slinger", "weight": 1.5 }, ] config.starting_health = 100 config.player_damage = 14 diff --git a/scripts/main.gd b/scripts/main.gd index 9c87a13..eb5843c 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -13,6 +13,7 @@ signal run_started(seed_value: int) @onready var monsters_root: Node2D = $World/Monsters @onready var pickups_root: Node2D = $World/Pickups @onready var effects_root: Node2D = $World/Effects +@onready var projectiles_root: Node2D = $World/Projectiles @onready var player: Player = $World/Player @onready var camera: Camera2D = $Camera @onready var hud: Hud = $UI/HUD @@ -22,6 +23,7 @@ signal run_started(seed_value: int) const MONSTER_SCENE := preload("res://scenes/actors/monster.tscn") const PICKUP_SCENE := preload("res://scenes/actors/pickup.tscn") +const PROJECTILE_SCENE := preload("res://scenes/actors/projectile.tscn") var run: DungeonResult = null var biome: DungeonConfig = null @@ -144,6 +146,7 @@ func _spawn_monster(p_position: Vector2i, p_monster_id: StringName) -> void: monster.setup(spec, p_position, dungeon_view, occupancy, player) occupancy[p_position] = monster monster.attack_player.connect(_on_monster_attack) + monster.shoot_requested.connect(_on_monster_shoot) monster.died.connect(_on_monster_died) monster_index += 1 @@ -189,6 +192,16 @@ func _on_player_attack(p_origin: Vector2i, p_facing: Vector2i, p_range: float, p func _on_monster_attack(p_damage: int) -> void: player.take_damage(p_damage) +## Spawns a projectile when a ranged monster fires. +func _on_monster_shoot(p_origin: Vector2i, p_target: Vector2i, p_spec: MonsterSpec) -> void: + var projectile: Projectile = PROJECTILE_SCENE.instantiate() + projectiles_root.add_child(projectile) + projectile.setup(p_spec, p_origin, p_target, dungeon_view, player) + projectile.hit_player.connect(_on_projectile_hit) + +func _on_projectile_hit(p_damage: int) -> void: + player.take_damage(p_damage) + func _on_monster_died(p_monster: MonsterActor) -> void: occupancy.erase(p_monster.grid_pos) var drop_rng := SeededRng.new(run_seed ^ (monster_index * 0x9E37)) @@ -290,4 +303,6 @@ func _clear_world() -> void: child.queue_free() for child in effects_root.get_children(): child.queue_free() + for child in projectiles_root.get_children(): + child.queue_free() _beacon = null diff --git a/scripts/world/tile_art.gd b/scripts/world/tile_art.gd index 2b0115a..237927f 100644 --- a/scripts/world/tile_art.gd +++ b/scripts/world/tile_art.gd @@ -20,6 +20,8 @@ const TILE_EXIT := &"exit" const ENTITY_KEYS := [ &"player", &"key", &"potion", &"coin", &"skeleton", &"crawler", &"wisp", &"shambler", &"golem", + &"bonecaster", &"spitter", &"slinger", + &"bolt", &"spit", &"ember", ] static var _tile_cache: Dictionary = {} @@ -90,6 +92,18 @@ static func _entity_pattern(p_key: StringName) -> Array: return _SHAMBLER &"golem": return _GOLEM + &"bonecaster": + return _BONECASTER + &"spitter": + return _SPITTER + &"slinger": + return _SLINGER + &"bolt": + return _BOLT + &"spit": + return _SPIT + &"ember": + return _EMBER &"key": return _KEY &"potion": @@ -116,6 +130,18 @@ static func _entity_palette(p_key: StringName) -> Dictionary: return { &"@": Color("#4f7a3f"), &"*": Color("#16240f"), &"+": Color("#6f9a58") } &"golem": return { &"@": Color("#8a6a52"), &"*": Color("#2a1f18"), &"+": Color("#b09070") } + &"bonecaster": + return { &"@": Color("#c9b89a"), &"*": Color("#7a5a3a"), &"+": Color("#e8e0cc") } + &"spitter": + return { &"@": Color("#7ab84c"), &"*": Color("#2a4a1a"), &"+": Color("#a8d86a") } + &"slinger": + return { &"@": Color("#d86a3a"), &"*": Color("#3a1a10"), &"+": Color("#f09058") } + &"bolt": + return { &"@": Color("#9fb4c9"), &"*": Color("#e8f4ff") } + &"spit": + return { &"@": Color("#7ab84c"), &"*": Color("#e8ffb0") } + &"ember": + return { &"@": Color("#d86a3a"), &"*": Color("#ffe8b0"), &"+": Color("#f0a050") } &"key": return { &"o": Color("#e8b84c"), &"@": Color("#b8860b"), &"+": Color("#fff0b0") } &"potion": @@ -273,6 +299,72 @@ const _GOLEM := [ "........", ] +const _BONECASTER := [ + "..@@@...", + ".@*+*@..", + ".@@*@@..", + "...@@...", + "..@@@@..", + ".@..@@@.", + ".@.@@@@.", + "........", +] + +const _SPITTER := [ + "........", + "..@@@...", + ".@+@+@..", + ".@@@@@@.", + ".@@*@@@.", + ".@@@@@@.", + "..@@@@..", + "........", +] + +const _SLINGER := [ + "..@@@...", + ".@+++@..", + ".@@@@@@.", + ".@@*@@@.", + ".@@@@@@.", + ".@@@@@@.", + "..@@@...", + "........", +] + +const _BOLT := [ + "........", + "...@@...", + "..@@@@..", + ".@@@@@@.", + "..@@@@..", + "...@@...", + "........", + "........", +] + +const _SPIT := [ + "........", + "..@@@...", + ".@@@@@..", + ".@@@@@@.", + ".@@@@@@.", + ".@@@@@@.", + ".@@@@@..", + "........", +] + +const _EMBER := [ + "........", + "...@@...", + "..@++@..", + "..@@@@..", + ".@+@@+@.", + ".@@@@@@.", + "..@@@@..", + "........", +] + const _KEY := [ "........", "...oooo.", diff --git a/tests/integration/test_ranged_combat.gd b/tests/integration/test_ranged_combat.gd new file mode 100644 index 0000000..08499cc --- /dev/null +++ b/tests/integration/test_ranged_combat.gd @@ -0,0 +1,104 @@ +extends GutTest +## Ranged combat end to end: monsters fire bolts and walls stop them. + +const MONSTER_SCENE := preload("res://scenes/actors/monster.tscn") +const PROJECTILE_SCENE := preload("res://scenes/actors/projectile.tscn") + +func _open_hall() -> DungeonMap: + var map := DungeonMap.new(16, 5) + for x in range(1, 15): + map.set_tile(x, 2, DungeonMap.Tile.FLOOR) + return map + +func test_ranged_monster_fires_and_the_bolt_damages_the_hero() -> void: + var view := DungeonView.new() + add_child_autofree(view) + view.configure(_open_hall(), Biomes.crypt()) + + var occupancy := {} + var hero := Player.new() + hero.setup( + CombatStats.make({ "max_health": 100, "health": 100 }), + Vector2i(2, 2), view, occupancy + ) + add_child_autofree(hero) + occupancy[hero.grid_pos] = hero + + var monster: MonsterActor = MONSTER_SCENE.instantiate() + add_child_autofree(monster) + var spec := MonsterSpecs.bonecaster() + monster.setup(spec, Vector2i(8, 2), view, occupancy, hero) + occupancy[monster.grid_pos] = monster + + var shots: Array[int] = [] + var bolts: Array = [] + monster.shoot_requested.connect( + func(_p_origin: Vector2i, p_target: Vector2i, p_spec: MonsterSpec) -> void: + shots.append(1) + var bolt: Projectile = PROJECTILE_SCENE.instantiate() + add_child(bolt) + bolts.append(bolt) + bolt.setup(p_spec, monster.grid_pos, p_target, view, hero) + bolt.hit_player.connect(hero.take_damage) + ) + + await wait_physics_frames(240) + assert_gt(shots.size(), 0, "the ranged monster never fired") + assert_lt(hero.stats.health, 100, "a bolt never damaged the hero") + for bolt in bolts: + if is_instance_valid(bolt): + bolt.free() + +func test_a_bolt_stops_at_a_wall_before_the_hero() -> void: + var map := DungeonMap.new(14, 5) + for x in range(1, 6): + map.set_tile(x, 2, DungeonMap.Tile.FLOOR) + for x in range(8, 13): + map.set_tile(x, 2, DungeonMap.Tile.FLOOR) + var view := DungeonView.new() + add_child_autofree(view) + view.configure(map, Biomes.crypt()) + + var occupancy := {} + var hero := Player.new() + hero.setup( + CombatStats.make({ "max_health": 100, "health": 100 }), + Vector2i(11, 2), view, occupancy + ) + add_child_autofree(hero) + + var bolt: Projectile = PROJECTILE_SCENE.instantiate() + add_child(bolt) + var spec := MonsterSpecs.bonecaster() + bolt.setup(spec, Vector2i(2, 2), Vector2i(11, 2), view, hero) + bolt.hit_player.connect(hero.take_damage) + + await wait_physics_frames(90) + assert_eq(hero.stats.health, 100, "a wall must stop the bolt before the hero") + if is_instance_valid(bolt): + bolt.free() + +func test_ranged_monster_keeps_distance_from_a_close_hero() -> void: + var view := DungeonView.new() + add_child_autofree(view) + view.configure(_open_hall(), Biomes.crypt()) + + var occupancy := {} + var hero := Player.new() + hero.setup( + CombatStats.make({ "max_health": 100, "health": 100 }), + Vector2i(4, 2), view, occupancy + ) + add_child_autofree(hero) + occupancy[hero.grid_pos] = hero + + var monster: MonsterActor = MONSTER_SCENE.instantiate() + add_child_autofree(monster) + var spec := MonsterSpecs.bonecaster() + monster.setup(spec, Vector2i(6, 2), view, occupancy, hero) + occupancy[monster.grid_pos] = monster + + await wait_physics_frames(180) + var distance := absi(monster.grid_pos.x - hero.grid_pos.x) \ + + absi(monster.grid_pos.y - hero.grid_pos.y) + assert_gt(distance, 2, "a ranged monster must flee when the hero is too close") diff --git a/tests/integration/test_ranged_combat.gd.uid b/tests/integration/test_ranged_combat.gd.uid new file mode 100644 index 0000000..4db90bc --- /dev/null +++ b/tests/integration/test_ranged_combat.gd.uid @@ -0,0 +1 @@ +uid://cnlv1trtfkkyq diff --git a/tests/unit/test_projectile.gd b/tests/unit/test_projectile.gd new file mode 100644 index 0000000..89944a0 --- /dev/null +++ b/tests/unit/test_projectile.gd @@ -0,0 +1,84 @@ +extends GutTest +## Projectile rules: aim, blocking tiles, and line of sight. + +func _build_open(p_width: int, p_height: int) -> DungeonMap: + var map := DungeonMap.new(p_width, p_height) + for x in p_width: + for y in p_height: + map.set_tile(x, y, DungeonMap.Tile.FLOOR) + return map + +func test_aim_points_from_origin_to_target() -> void: + assert_eq(Projectile.aim(Vector2(0, 0), Vector2(0, 16)), Vector2.DOWN) + assert_eq(Projectile.aim(Vector2(16, 0), Vector2(0, 0)), Vector2.LEFT) + +func test_aim_is_a_unit_vector_on_diagonals() -> void: + var direction := Projectile.aim(Vector2(0, 0), Vector2(16, 16)) + assert_almost_eq(direction.x, 0.7071, 0.001) + assert_almost_eq(direction.y, 0.7071, 0.001) + +func test_aim_on_same_position_falls_back() -> void: + assert_eq(Projectile.aim(Vector2(8, 8), Vector2(8, 8)), Vector2.RIGHT) + +func test_blocks_walls_and_locked_doors() -> void: + assert_true(Projectile.blocks(DungeonMap.Tile.WALL)) + assert_true(Projectile.blocks(DungeonMap.Tile.DOOR_LOCKED)) + +func test_open_floor_does_not_block() -> void: + assert_false(Projectile.blocks(DungeonMap.Tile.FLOOR)) + assert_false(Projectile.blocks(DungeonMap.Tile.DOOR_OPEN)) + assert_false(Projectile.blocks(DungeonMap.Tile.START)) + assert_false(Projectile.blocks(DungeonMap.Tile.EXIT)) + +func test_los_is_clear_along_open_row() -> void: + var map := _build_open(8, 8) + assert_true(Projectile.los_clear(map, Vector2i(1, 4), Vector2i(6, 4))) + +func test_los_same_cell_is_clear() -> void: + var map := _build_open(4, 4) + assert_true(Projectile.los_clear(map, Vector2i(2, 2), Vector2i(2, 2))) + +func test_los_is_clear_on_open_diagonal() -> void: + var map := _build_open(8, 8) + assert_true(Projectile.los_clear(map, Vector2i(1, 1), Vector2i(6, 6))) + +func test_los_is_blocked_by_a_wall_cell() -> void: + var map := _build_open(9, 9) + map.set_tile(4, 4, DungeonMap.Tile.WALL) + assert_false(Projectile.los_clear(map, Vector2i(1, 4), Vector2i(7, 4))) + +func test_los_is_blocked_by_a_locked_door() -> void: + var map := _build_open(9, 9) + map.set_tile(4, 4, DungeonMap.Tile.DOOR_LOCKED) + assert_false(Projectile.los_clear(map, Vector2i(1, 4), Vector2i(7, 4))) + +func test_los_ignores_wall_behind_the_target() -> void: + var map := _build_open(9, 9) + map.set_tile(7, 4, DungeonMap.Tile.WALL) + assert_true(Projectile.los_clear(map, Vector2i(1, 4), Vector2i(5, 4))) + +func test_every_ranged_spec_is_valid_and_balanced() -> void: + for spec in MonsterSpecs.all(): + if not spec.ranged: + continue + assert_true(spec.is_valid(), "ranged monster %s is invalid" % spec.id) + assert_true(TileArt.has_entity(spec.projectile_key), spec.id) + assert_gte(spec.projectile_range, spec.min_range, spec.id) + assert_gt(spec.projectile_damage, 0, spec.id) + assert_gt(spec.projectile_speed, 0.0, spec.id) + assert_eq(spec.ai, MonsterSpec.AI.ranged, spec.id) + +func test_melee_specs_are_not_ranged() -> void: + for spec in MonsterSpecs.all(): + if spec.id in [&"bonecaster", &"spitter", &"slinger"]: + assert_true(spec.ranged) + else: + assert_false(spec.ranged, spec.id) + +func test_every_biome_offers_a_ranged_monster() -> void: + for biome in Biomes.all(): + var ranged_ids := [] + for entry in biome.monster_table: + if MonsterSpecs.by_id(entry.monster).ranged: + ranged_ids.append(entry.monster) + assert_false(ranged_ids.is_empty(), "%s has no ranged monster" % biome.id) diff --git a/tests/unit/test_projectile.gd.uid b/tests/unit/test_projectile.gd.uid new file mode 100644 index 0000000..a799dba --- /dev/null +++ b/tests/unit/test_projectile.gd.uid @@ -0,0 +1 @@ +uid://cmfgub3swjmoe diff --git a/tools/smoke.gd b/tools/smoke.gd index 3b50254..62e2012 100644 --- a/tools/smoke.gd +++ b/tools/smoke.gd @@ -48,6 +48,25 @@ func _verify() -> void: elif not _main.run.solvable: _fail("dungeon is not solvable") _verify_input_bindings() + _verify_ranged() + +func _verify_ranged() -> void: + for spec in MonsterSpecs.all(): + if not spec.ranged: + continue + if not spec.is_valid(): + _fail("ranged monster %s is invalid" % spec.id) + if not TileArt.has_entity(spec.projectile_key): + _fail("ranged monster %s has no projectile sprite" % spec.id) + for biome in Biomes.all(): + var has_ranged := false + for entry in biome.monster_table: + if MonsterSpecs.by_id(entry.monster).ranged: + has_ranged = true + if not has_ranged: + _fail("biome %s has no ranged monster" % biome.id) + if not Projectile.blocks(DungeonMap.Tile.WALL): + _fail("projectiles must stop at walls") func _verify_input_bindings() -> void: for action in Controls.ACTIONS: