Now and then, a group of enemies in a tunnel
Tunnels are wide enough to fight in now, so occasionally one is occupied: each tunnel leg rolls a 10% chance of a group of two or three of one kind, standing within two tiles of each other. Most legs are too short or too near the entrance to qualify, so in practice it is about one depth-1 run in three (60 of 200 measured), rising with depth as tunnels get longer. 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. Never within aggro range of the entrance, checked per member rather than per group: members stand up to two tiles off the group's centre, so a centre that clears the entrance does not mean everyone does. The first version checked the centre, and the nearest member landed 15 units outside aggro range by luck. Never a lone straggler: a leg that cannot fit two gets nobody. Placed after every room, so each room's population is exactly what its seed always produced, and every leg rolls the same dice whether or not it is used, so one tunnel's luck never depends on another's shape. Scaled by the dungeon like every other spawn, each member from its own fresh definition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
855669a665
commit
069abdb3fa
4 files changed
+184
-6
No files matched your search
@@ -92,6 +92,7 @@ against itself.
|
|||||||
| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) |
|
| 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 |
|
| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
|
||||||
| Enemies placed per room at creation | done | `Instance._populate()` |
|
| 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) |
|
| 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` |
|
| 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` |
|
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
|
||||||
|
|||||||
@@ -31,10 +31,26 @@ var stage_delay: int = 0
|
|||||||
var boss_id: StringName = &""
|
var boss_id: StringName = &""
|
||||||
## Generated layout, used to place enemies room by room.
|
## Generated layout, used to place enemies room by room.
|
||||||
var rooms: Array[Rect2i] = []
|
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_spawn := Vector2.ZERO
|
||||||
var boss_room := Rect2i()
|
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:
|
static func make_lobby(instance_id: int) -> Instance:
|
||||||
var inst := Instance.new()
|
var inst := Instance.new()
|
||||||
inst.id = instance_id
|
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.depth, flavour.arena)
|
||||||
inst.world.set_map(built["grid"])
|
inst.world.set_map(built["grid"])
|
||||||
inst.rooms = built["rooms"]
|
inst.rooms = built["rooms"]
|
||||||
|
inst.tunnels = built["tunnels"]
|
||||||
inst.boss_spawn = built["boss_pos"]
|
inst.boss_spawn = built["boss_pos"]
|
||||||
inst.boss_room = built["boss_room"]
|
inst.boss_room = built["boss_room"]
|
||||||
inst.world.spawn_point = built["spawn"]
|
inst.world.spawn_point = built["spawn"]
|
||||||
@@ -119,6 +136,87 @@ func _populate() -> void:
|
|||||||
if world.map.circle_blocked(at, def.radius):
|
if world.map.circle_blocked(at, def.radius):
|
||||||
continue
|
continue
|
||||||
world.spawn_enemy(def, at, rng.randi_range(0, 120))
|
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:
|
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
|
||||||
|
|||||||
+12
-6
@@ -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,
|
# 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.
|
# so every room is reachable by construction rather than by luck.
|
||||||
var widths := PackedInt32Array()
|
var widths := PackedInt32Array()
|
||||||
|
var legs: Array[Rect2i] = []
|
||||||
for i in range(1, rooms.size()):
|
for i in range(1, rooms.size()):
|
||||||
var from := _centre_of(rooms[i - 1])
|
var from := _centre_of(rooms[i - 1])
|
||||||
widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]),
|
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():
|
if not rooms.is_empty():
|
||||||
# Aimed at the tile OUTSIDE the door, and always arriving along the
|
# 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
|
# 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.
|
# rather than eating into it.
|
||||||
var outside := door + Vector2i(-1, 0)
|
var outside := door + Vector2i(-1, 0)
|
||||||
widths.append_array(_carve_corridor(grid, rng,
|
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.
|
# 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(door.x, door.y, MapGrid.Kind.FLOOR)
|
||||||
grid.set_tile(outside.x, outside.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
|
# builds a world -- it is here so a test can check the widths actually
|
||||||
# vary, which the tiles alone cannot say once tunnels overlap.
|
# vary, which the tiles alone cannot say once tunnels overlap.
|
||||||
"corridor_widths": widths,
|
"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
|
# 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
|
# it anywhere else would let a dungeon put the Cantor in the Warden's
|
||||||
# hall, where its patterns assume geometry that is not there.
|
# 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.
|
## walls it was drawn with.
|
||||||
static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
|
static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
|
||||||
from: Vector2i, to: Vector2i, horizontal_first: bool,
|
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 first := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
|
||||||
var second := 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)
|
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
|
# 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
|
# below its width -- it just bevels the outer corner, which reads as a
|
||||||
# notch rather than a turn.
|
# notch rather than a turn.
|
||||||
_carve_leg(grid, from, bend, first, second / 2, keep_out)
|
legs.append(_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, bend, to, second, first / 2, keep_out))
|
||||||
return PackedInt32Array([first, second])
|
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
|
## 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.
|
## 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,
|
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
|
# 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.
|
# and the one after it, three is one either side, four leans one way.
|
||||||
var lo := -(width - 1) / 2
|
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)):
|
if keep_out.has_point(Vector2i(x, y)):
|
||||||
continue
|
continue
|
||||||
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
||||||
|
return rect
|
||||||
|
|
||||||
|
|
||||||
## A few pillars and pits inside generated rooms, so open rooms still have
|
## A few pillars and pits inside generated rooms, so open rooms still have
|
||||||
|
|||||||
@@ -138,3 +138,76 @@ func test_interacting_away_from_the_portal_does_nothing() -> void:
|
|||||||
var used := lobby.world.events.filter(
|
var used := lobby.world.events.filter(
|
||||||
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
|
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
|
||||||
assert_eq(used.size(), 0)
|
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)
|
||||||
Reference in new issue
Block a user