diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 94937af..901864d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -92,6 +92,7 @@ against itself. | Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) | | Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables | | Enemies placed per room at creation | done | `Instance._populate()` | +| Occasional enemy groups in tunnels | done | `Instance._populate_tunnels()` — about one depth-1 run in three, never within aggro range of the entrance | | Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) | | Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` | | Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` | diff --git a/src/instances/instance.gd b/src/instances/instance.gd index 4d3f9a8..0edc293 100644 --- a/src/instances/instance.gd +++ b/src/instances/instance.gd @@ -31,10 +31,26 @@ var stage_delay: int = 0 var boss_id: StringName = &"" ## Generated layout, used to place enemies room by room. var rooms: Array[Rect2i] = [] +## The rectangle each tunnel leg was carved over -- see MapGen. Overlaps the +## rooms at its ends, so _tunnel_tiles() decides what is actually tunnel. +var tunnels: Array[Rect2i] = [] var boss_spawn := Vector2.ZERO var boss_room := Rect2i() +## Chance that any one tunnel leg has a group of enemies in it. Rolled per leg, +## and most legs are too short or too near the entrance to qualify anyway, so a +## depth-1 dungeon has a tunnel group about one run in three. Rare on purpose: +## tunnels are where you go between fights, and a tunnel that is usually empty +## is what makes the one that is not a surprise. +const TUNNEL_GROUP_CHANCE := 0.1 +const TUNNEL_GROUP_MIN := 2 +const TUNNEL_GROUP_MAX := 3 +## Members stand within this many tiles of the group's centre, so a group reads +## as one encounter rather than a tunnel's worth of strays. +const TUNNEL_GROUP_SPREAD := 2 + + static func make_lobby(instance_id: int) -> Instance: var inst := Instance.new() inst.id = instance_id @@ -78,6 +94,7 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int inst.depth, flavour.arena) inst.world.set_map(built["grid"]) inst.rooms = built["rooms"] + inst.tunnels = built["tunnels"] inst.boss_spawn = built["boss_pos"] inst.boss_room = built["boss_room"] inst.world.spawn_point = built["spawn"] @@ -119,6 +136,87 @@ func _populate() -> void: if world.map.circle_blocked(at, def.radius): continue world.spawn_enemy(def, at, rng.randi_range(0, 120)) + # After every room, so adding tunnel groups leaves each room's population + # exactly what that seed always produced. + _populate_tunnels(rng, flavour) + + +## Now and then, a group of enemies in a tunnel. +## +## Never within aggro range of the entrance, by the same reasoning room 0 is +## left empty: arriving into a fight you cannot see yet is not a decision, it +## is an ambush. And never a lone straggler -- if a leg cannot fit at least +## TUNNEL_GROUP_MIN, it gets nobody. +func _populate_tunnels(rng: RandomNumberGenerator, flavour: DungeonDef) -> void: + var clear_of_spawn := _widest_aggro() + MapGrid.TILE * 2.0 + for leg in tunnels: + # Every leg rolls the same three numbers whether or not it is used, so + # one tunnel's dice never depend on another tunnel's shape. + var roll := rng.randf() + var pick := rng.randi() + var size := rng.randi_range(TUNNEL_GROUP_MIN, TUNNEL_GROUP_MAX) + if roll >= TUNNEL_GROUP_CHANCE: + continue + var open := _tunnel_tiles(leg) + if open.size() < TUNNEL_GROUP_MAX * 2: + continue + var centre: Vector2i = open[pick % open.size()] + var def := flavour.apply_to_enemy(_pick_enemy(rng)) + var spots: Array[Vector2] = [] + for t in open: + if absi(t.x - centre.x) > TUNNEL_GROUP_SPREAD \ + or absi(t.y - centre.y) > TUNNEL_GROUP_SPREAD: + continue + var at := world.map.tile_centre(t.x, t.y) + # Per member, not per group: members stand up to two tiles off the + # centre, so a centre that clears the entrance does not mean every + # member does. + if at.distance_to(world.spawn_point) < clear_of_spawn: + continue + if not world.map.circle_blocked(at, def.radius): + spots.append(at) + if spots.size() < TUNNEL_GROUP_MIN: + continue + for i in range(spots.size() - 1, 0, -1): + var j := rng.randi_range(0, i) + var held := spots[i] + spots[i] = spots[j] + spots[j] = held + for i in mini(size, spots.size()): + # A fresh definition per member: apply_to_enemy mutates in place, + # and sharing one object would scale it once per member. + var member := def if i == 0 else flavour.apply_to_enemy(Content.enemy(def.id)) + world.spawn_enemy(member, spots[i], rng.randi_range(0, 120)) + + +## Floor tiles of [param leg] that are tunnel rather than room: clear of every +## room by a tile, so a group is never standing in a doorway, and outside the +## boss arena. +func _tunnel_tiles(leg: Rect2i) -> Array[Vector2i]: + var out: Array[Vector2i] = [] + for ty in range(leg.position.y, leg.end.y): + for tx in range(leg.position.x, leg.end.x): + if world.map.at(tx, ty) != MapGrid.Kind.FLOOR: + continue + var here := Vector2i(tx, ty) + if boss_room.has_point(here): + continue + var in_room := false + for r in rooms: + if r.grow(1).has_point(here): + in_room = true + break + if not in_room: + out.append(here) + return out + + +## The furthest any enemy in the game notices a player from. +static func _widest_aggro() -> float: + var widest := 0.0 + for id in Content.ALL_ENEMIES: + widest = maxf(widest, Content.enemy(id).aggro_range) + return widest func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef: diff --git a/src/sim/map_gen.gd b/src/sim/map_gen.gd index 25cb807..ecb5a73 100644 --- a/src/sim/map_gen.gd +++ b/src/sim/map_gen.gd @@ -151,10 +151,11 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di # Connect each room to the previous one, then the last to the arena door, # so every room is reachable by construction rather than by luck. var widths := PackedInt32Array() + var legs: Array[Rect2i] = [] for i in range(1, rooms.size()): var from := _centre_of(rooms[i - 1]) widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]), - (from.x + from.y) % 2 == 0, boss_room)) + (from.x + from.y) % 2 == 0, boss_room, legs)) if not rooms.is_empty(): # Aimed at the tile OUTSIDE the door, and always arriving along the # door's own row. Aiming at the door itself, an L that bent the other @@ -164,7 +165,7 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di # rather than eating into it. var outside := door + Vector2i(-1, 0) widths.append_array(_carve_corridor(grid, rng, - _centre_of(rooms[rooms.size() - 1]), outside, false, boss_room)) + _centre_of(rooms[rooms.size() - 1]), outside, false, boss_room, legs)) # The door tile itself, and the tile outside it, must be floor. grid.set_tile(door.x, door.y, MapGrid.Kind.FLOOR) grid.set_tile(outside.x, outside.y, MapGrid.Kind.FLOOR) @@ -193,6 +194,10 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di # builds a world -- it is here so a test can check the widths actually # vary, which the tiles alone cannot say once tunnels overlap. "corridor_widths": widths, + # The rectangle each tunnel leg was carved over. Overlaps the rooms it + # joins at its ends, so whoever uses it decides what counts as tunnel -- + # Instance._populate_tunnels keeps only floor clear of every room. + "tunnels": legs, # Which boss lives here follows from WHICH arena was stamped. Choosing # it anywhere else would let a dungeon put the Cantor in the Warden's # hall, where its patterns assume geometry that is not there. @@ -214,7 +219,7 @@ static func _centre_of(r: Rect2i) -> Vector2i: ## walls it was drawn with. static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator, from: Vector2i, to: Vector2i, horizontal_first: bool, - keep_out: Rect2i) -> PackedInt32Array: + keep_out: Rect2i, legs: Array[Rect2i]) -> PackedInt32Array: var first := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH) var second := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH) var bend := Vector2i(to.x, from.y) if horizontal_first else Vector2i(from.x, to.y) @@ -223,8 +228,8 @@ static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator, # the INNER side of the bend, so leaving this out never narrows a tunnel # below its width -- it just bevels the outer corner, which reads as a # notch rather than a turn. - _carve_leg(grid, from, bend, first, second / 2, keep_out) - _carve_leg(grid, bend, to, second, first / 2, keep_out) + legs.append(_carve_leg(grid, from, bend, first, second / 2, keep_out)) + legs.append(_carve_leg(grid, bend, to, second, first / 2, keep_out)) return PackedInt32Array([first, second]) @@ -235,7 +240,7 @@ static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator, ## world is sealed by construction, so a wide tunnel that hugs the edge must ## stop one tile short of it rather than open the map to nothing. static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int, - overshoot: int, keep_out: Rect2i) -> void: + overshoot: int, keep_out: Rect2i) -> Rect2i: # Width is laid across the leg, centred on its line: two tiles is the line # and the one after it, three is one either side, four leans one way. var lo := -(width - 1) / 2 @@ -254,6 +259,7 @@ static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int, if keep_out.has_point(Vector2i(x, y)): continue grid.set_tile(x, y, MapGrid.Kind.FLOOR) + return rect ## A few pillars and pits inside generated rooms, so open rooms still have diff --git a/tests/integration/test_dungeon_flow.gd b/tests/integration/test_dungeon_flow.gd index 67ce115..5db2e8d 100644 --- a/tests/integration/test_dungeon_flow.gd +++ b/tests/integration/test_dungeon_flow.gd @@ -138,3 +138,76 @@ func test_interacting_away_from_the_portal_does_nothing() -> void: var used := lobby.world.events.filter( func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED) assert_eq(used.size(), 0) + + +# --- Tunnel groups ---------------------------------------------------------- + +func _tunnel_enemies(run: Instance) -> Array[SimEnemy]: + var out: Array[SimEnemy] = [] + for e in run.world.enemies.values(): + var t := run.world.map.to_tile(e.pos) + if run.boss_room.has_point(t): + continue + var in_room := false + for r in run.rooms: + if r.grow(1).has_point(t): + in_room = true + if not in_room: + out.append(e) + return out + + +func test_tunnels_are_occasionally_occupied_and_usually_not() -> void: + # "Rarely" in numbers: across many depth-1 runs, some have a group in a + # tunnel and most do not. + var occupied := 0 + var runs := 80 + for s in range(1, runs + 1): + if not _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1)).is_empty(): + occupied += 1 + assert_gt(occupied, runs / 10, "tunnel groups exist (%d of %d runs)" % [occupied, runs]) + assert_lt(occupied, runs / 2, "but a run with one is the exception (%d of %d)" % [occupied, runs]) + + +func test_a_tunnel_group_is_never_awake_when_you_arrive() -> void: + var widest := Instance._widest_aggro() + for s in range(1, 120): + var run := Instance.make_dungeon(2, s * 7919, 1 + (s % 3)) + for e in _tunnel_enemies(run): + assert_gt(e.pos.distance_to(run.world.spawn_point), widest, + "seed %d: a tunnel %s stands within aggro range of the entrance" + % [s * 7919, e.def.id]) + + +func test_a_tunnel_group_is_a_group() -> void: + # Never a lone straggler, and never spread down the whole tunnel: every + # member has another within a few tiles. + var near := MapGrid.TILE * (Instance.TUNNEL_GROUP_SPREAD * 2 + 1) + var seen := 0 + for s in range(1, 120): + var found := _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1 + (s % 3))) + for e in found: + seen += 1 + var has_company := false + for other in found: + if other != e and other.pos.distance_to(e.pos) <= near: + has_company = true + assert_true(has_company, "seed %d: a tunnel %s on its own" % [s * 7919, e.def.id]) + assert_gt(seen, 0, "the sample produced at least one group to check") + + +func test_a_tunnel_group_is_scaled_like_everything_else() -> void: + # The Proving Grounds scales every enemy it spawns; a tunnel group must not + # be the one thing that arrives at full health in the easy harness. + for s in range(1, 120): + var run := Instance.make_dungeon(2, s * 7919, 2, Dungeons.PROVING) + for e in _tunnel_enemies(run): + assert_lt(e.hp, Content.enemy(e.def.id).max_hp, + "seed %d: a tunnel %s skipped the dungeon's scaling" % [s * 7919, e.def.id]) + + +func test_boss_rush_leaves_the_tunnels_empty_too() -> void: + GameOpts.boss_rush = true + var run := Instance.make_dungeon(2, 4242, 3) + GameOpts.boss_rush = false + assert_eq(_tunnel_enemies(run).size(), 0)