Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
ci / verify (push) Successful in 46s

Replaces the fixed centred arena with per-world tile geometry, which is the
foundation the remaining features sit on.

- MapGrid: tile grid with three independent flags -- blocks movement, bullets,
  sight -- so a pit stops feet but not bullets or eyes, and a barricade stops
  feet and bullets but not eyes. Circle collision with per-axis sliding, and
  Bresenham line of sight shared by fog and aggro.
- MapGen: rooms and corridors generated from (seed, depth), with hand-authored
  boss arenas from Rooms stamped in first so a corridor can never carve through
  a designed fight. Reachability from spawn to boss is asserted over 40 seeds --
  "usually connected" is the failure mode that ruins one run in twenty.
- Dungeons are populated at creation, per room, instead of gating on waves. You
  explore and choose your fights; the run ends when the boss dies, not when the
  map is swept. Boss rooms have no lock, so walking out is always available.
- Aggro: enemies need range AND line of sight, so a dungeon stays quiet until
  engaged and cover actually protects.
- Hard fog, scrolling camera, and terrain rendering.

Maps are streamed per peer in chunks around that peer's player, and the seed is
deliberately NOT sent -- a client holding it could regenerate the whole dungeon,
which is a map hack for free. Stream radius (900u) is wider than view radius
(460u) because the client predicts movement against walls and simulates bullets
that die on them; the accepted cost is a cheater seeing a little further than
the fog, never the floor plan.

Partial map knowledge means wall deaths must be announced rather than derived.
A test caught the subtle half of that: out-of-bounds tiles read as WALL by
design, so checking geometry before bounds reported every bullet leaving the map
as a wall kill.

128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4
players and a boss, ~65x headroom.
This commit is contained in:
2026-09-03 20:49:27 +02:00
parent e1f9fa8096
commit 7af439341d
34 changed files with 1522 additions and 196 deletions
+125
View File
@@ -0,0 +1,125 @@
class_name Rooms
extends RefCounted
## Hand-authored room stamps, as text.
##
## Boss arenas are authored rather than generated: a boss fight is a designed
## space -- sightlines, cover, room to dodge a wall pattern -- and a generator
## that produces "a room" produces a bad one about as often as a good one. The
## corridors and filler rooms around them are generated; the fights are not.
##
## Legend:
## # wall o pillar ~ pit = barricade
## . floor B boss spawn D doorway (floor, corridors connect here)
const LEGEND := {
"#": MapGrid.Kind.WALL,
"o": MapGrid.Kind.PILLAR,
"~": MapGrid.Kind.PIT,
"=": MapGrid.Kind.BARRICADE,
".": MapGrid.Kind.FLOOR,
"B": MapGrid.Kind.FLOOR,
"D": MapGrid.Kind.FLOOR,
"P": MapGrid.Kind.FLOOR,
"S": MapGrid.Kind.FLOOR,
"T": MapGrid.Kind.FLOOR,
}
## Legend characters that record a position rather than only painting a tile.
const MARKERS := ["B", "D", "P", "S", "T"]
## Wide, with pillars to break the Warden's rings and pits that shape where you
## can retreat to. The door is deliberately on one wall only: the boss cannot
## follow you out, so the exit is a real decision rather than an accident.
static func warden_hall() -> PackedStringArray:
return PackedStringArray([
"#########################",
"#.......................#",
"#..~~~...........~~~....#",
"#..~~~....o.o....~~~....#",
"#.........................",
"#....o.................o#",
"#.............B.........#",
"#....o.................o#",
"D.........................",
"#..~~~....o.o....~~~....#",
"#..~~~..........~~~.....#",
"#.......................#",
"#########################",
])
## Tighter and more claustrophobic, with barricades you can see over but not
## shoot through -- a room that rewards knowing where the boss is without
## giving you a firing lane to it.
static func choir_vault() -> PackedStringArray:
return PackedStringArray([
"#####################",
"#...................#",
"#..===.......===....#",
"#...................#",
"#....o.......o......#",
"D.........B.........#",
"#....o.......o......#",
"#...................#",
"#..===.......===....#",
"#...................#",
"#####################",
])
static func size_of(stamp: PackedStringArray) -> Vector2i:
if stamp.is_empty():
return Vector2i.ZERO
var w := 0
for row in stamp:
w = maxi(w, row.length())
return Vector2i(w, stamp.size())
## Paint a stamp into [param grid] with its top-left at [param origin].
## Returns the marker positions found, keyed by their legend character.
static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dictionary:
var markers := {}
for m in MARKERS:
markers[m] = []
for y in s.size():
var row := s[y]
for x in row.length():
var ch := row[x]
if not LEGEND.has(ch):
continue
var tx := origin.x + x
var ty := origin.y + y
grid.set_tile(tx, ty, LEGEND[ch])
if markers.has(ch):
markers[ch].append(Vector2i(tx, ty))
return markers
## The hub. Hand-authored like the boss arenas, and reproduced identically on
## the client from this same function -- see MapGen.build().
## P portal to the dungeons S player spawn T practice target
static func lobby() -> PackedStringArray:
return PackedStringArray([
"#########################################",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#...................P...................#",
"#.......................................#",
"#.....o...........................o.....#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.........T.............................#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.....o...........................o.....#",
"#...................S...................#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#########################################",
])