Files
transcience/src/sim/map_grid.gd
T
claude 7af439341d
ci / verify (push) Successful in 46s
Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
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.
2026-09-03 20:49:27 +02:00

281 lines
9.1 KiB
GDScript

class_name MapGrid
extends RefCounted
## The static geometry of one world: a tile grid with per-tile movement, bullet
## and sight blocking.
##
## A grid rather than freeform shapes because three separate systems need to ask
## spatial questions cheaply and identically on both server and client -- circle
## collision, bullet collision, and line of sight for fog and interest
## management. On a grid all three are array lookups; on polygons they are
## intersection tests, and the fog algorithm in particular stops being tractable.
##
## Coordinates: the grid's top-left tile is world (0, 0), and world space runs
## to (width * TILE, height * TILE). The old centre-origin arena is gone -- with
## maps of varying size there is no meaningful centre to anchor to.
const TILE := 32.0
enum Kind {
FLOOR,
## Full-height: stops movement, bullets and sight.
WALL,
## Same as WALL, drawn differently. Kept distinct so generators can place
## cover without it reading as a room boundary.
PILLAR,
## Cross it with a bullet or your eyes, but not with your feet.
PIT,
## Chest height: blocks movement and bullets, but you can see over it.
BARRICADE,
## Not yet streamed to this client. Only ever appears in a client's copy --
## a server map is fully known by construction. Treated as empty so an
## un-streamed region cannot wrongly stop a prediction; the stream radius is
## kept well ahead of the player so this never decides anything visible.
UNKNOWN,
}
## Tiles per chunk edge. Small enough that a player near one corner of a map
## learns a small fraction of it, which is the entire point of streaming rather
## than sending the map (or its seed) up front.
const CHUNK := 8
## Parallel flag tables, indexed by Kind. Three independent booleans rather than
## one "solid" flag, because the interesting tiles are exactly the ones that
## block some things and not others.
const BLOCKS_MOVE := [false, true, true, true, true, false]
const BLOCKS_BULLET := [false, true, true, false, true, false]
const BLOCKS_SIGHT := [false, true, true, false, false, false]
var width: int = 0
var height: int = 0
## Row-major, width * height entries of Kind.
var tiles := PackedByteArray()
## World position of tile (0, 0)'s top-left corner. Generated maps set this to
## -world_size()/2 so the world stays centred on the origin, which keeps every
## existing coordinate (spawn points, portal, boss placement) meaningful and
## avoids an all-positive coordinate space where "0" is a corner.
var origin := Vector2.ZERO
func _init(w: int = 1, h: int = 1, fill: Kind = Kind.WALL) -> void:
resize(w, h, fill)
func resize(w: int, h: int, fill: Kind = Kind.WALL) -> void:
width = maxi(w, 1)
height = maxi(h, 1)
tiles.resize(width * height)
tiles.fill(fill)
func in_bounds(tx: int, ty: int) -> bool:
return tx >= 0 and ty >= 0 and tx < width and ty < height
## Out-of-bounds reads as WALL so callers never have to bounds-check before
## asking; the world is sealed by construction.
func at(tx: int, ty: int) -> Kind:
if not in_bounds(tx, ty):
return Kind.WALL
return tiles[ty * width + tx] as Kind
func set_tile(tx: int, ty: int, kind: Kind) -> void:
if in_bounds(tx, ty):
tiles[ty * width + tx] = kind
func fill_rect(rect: Rect2i, kind: Kind) -> void:
for ty in range(rect.position.y, rect.end.y):
for tx in range(rect.position.x, rect.end.x):
set_tile(tx, ty, kind)
# --- Space conversion -------------------------------------------------------
func world_size() -> Vector2:
return Vector2(float(width), float(height)) * TILE
## Centre of a tile, which is what actors are placed on.
func tile_centre(tx: int, ty: int) -> Vector2:
return origin + Vector2(float(tx) + 0.5, float(ty) + 0.5) * TILE
func to_tile(world: Vector2) -> Vector2i:
var local := world - origin
# floor(), never int(): truncation folds -0.5 onto tile 0 and would let an
# actor stand half a tile outside the map.
return Vector2i(int(floor(local.x / TILE)), int(floor(local.y / TILE)))
## Centre the map on the world origin.
func centre_on_origin() -> void:
origin = -world_size() * 0.5
## World-space rectangle the map occupies.
func world_rect() -> Rect2:
return Rect2(origin, world_size())
# --- Queries ----------------------------------------------------------------
func blocks_move(tx: int, ty: int) -> bool:
return BLOCKS_MOVE[at(tx, ty)]
func blocks_bullet(tx: int, ty: int) -> bool:
return BLOCKS_BULLET[at(tx, ty)]
func blocks_sight(tx: int, ty: int) -> bool:
return BLOCKS_SIGHT[at(tx, ty)]
## True when a bullet at this world point should die. Bullets are small enough
## that a point test against the tile they are in is indistinguishable from a
## circle test, and it keeps server and client trivially identical.
func bullet_blocked(world: Vector2) -> bool:
var t := to_tile(world)
return blocks_bullet(t.x, t.y)
## Circle-vs-grid overlap for actor collision.
func circle_blocked(centre: Vector2, radius: float) -> bool:
var lo := to_tile(centre - Vector2(radius, radius))
var hi := to_tile(centre + Vector2(radius, radius))
for ty in range(lo.y, hi.y + 1):
for tx in range(lo.x, hi.x + 1):
if not blocks_move(tx, ty):
continue
if _circle_hits_tile(centre, radius, tx, ty):
return true
return false
func _circle_hits_tile(centre: Vector2, radius: float, tx: int, ty: int) -> bool:
# Closest point on the tile's AABB to the circle centre.
var lo := origin + Vector2(float(tx), float(ty)) * TILE
var closest := Vector2(
clampf(centre.x, lo.x, lo.x + TILE),
clampf(centre.y, lo.y, lo.y + TILE))
return centre.distance_squared_to(closest) < radius * radius
## Move a circle by [param delta], resolving each axis separately so that
## running into a wall at an angle slides along it instead of stopping dead.
func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2:
var out := pos
var try_x := Vector2(out.x + delta.x, out.y)
if not circle_blocked(try_x, radius):
out = try_x
var try_y := Vector2(out.x, out.y + delta.y)
if not circle_blocked(try_y, radius):
out = try_y
return out
## Bresenham-style sight test between two world points. Used for fog on the
## client and for aggro on the server, so it has to agree on both.
func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
var a := to_tile(from)
var b := to_tile(to)
var dx := absi(b.x - a.x)
var dy := -absi(b.y - a.y)
var sx := 1 if a.x < b.x else -1
var sy := 1 if a.y < b.y else -1
var err := dx + dy
var x := a.x
var y := a.y
# Guard against a pathological ray in a huge map costing unbounded time.
var steps := 0
var limit := width + height + 4
while steps < limit:
steps += 1
if x == b.x and y == b.y:
return true
# The endpoints themselves never block: standing in a doorway, or
# shooting at something embedded in a wall, must still resolve.
if not (x == a.x and y == a.y) and blocks_sight(x, y):
return false
var e2 := 2 * err
if e2 >= dy:
err += dy
x += sx
if e2 <= dx:
err += dx
y += sy
return false
# --- Chunked streaming ------------------------------------------------------
# The server never sends a whole map, and never sends the seed it was generated
# from: either would let a modified client draw the entire dungeon. Tiles are
# streamed per peer in chunks around where that player actually is, so a map
# hack can reveal a little more than the fog shows and no more.
func chunks_wide() -> int:
return int(ceil(float(width) / float(CHUNK)))
func chunks_high() -> int:
return int(ceil(float(height) / float(CHUNK)))
func chunk_count() -> int:
return chunks_wide() * chunks_high()
func chunk_id_at(tx: int, ty: int) -> int:
return (ty / CHUNK) * chunks_wide() + (tx / CHUNK)
## Tile-space rect a chunk covers, clipped to the map.
func chunk_rect(chunk_id: int) -> Rect2i:
var cw := chunks_wide()
if cw <= 0:
return Rect2i()
var cx := (chunk_id % cw) * CHUNK
var cy := (chunk_id / cw) * CHUNK
return Rect2i(cx, cy, mini(CHUNK, width - cx), mini(CHUNK, height - cy))
## Chunk ids whose tiles fall within [param radius] world units of [param at].
func chunks_near(at: Vector2, radius: float) -> PackedInt32Array:
var out := PackedInt32Array()
var lo := to_tile(at - Vector2(radius, radius))
var hi := to_tile(at + Vector2(radius, radius))
var cw := chunks_wide()
var ch := chunks_high()
var c_lo_x := clampi(lo.x / CHUNK, 0, cw - 1)
var c_hi_x := clampi(hi.x / CHUNK, 0, cw - 1)
var c_lo_y := clampi(lo.y / CHUNK, 0, ch - 1)
var c_hi_y := clampi(hi.y / CHUNK, 0, ch - 1)
for cy in range(c_lo_y, c_hi_y + 1):
for cx in range(c_lo_x, c_hi_x + 1):
out.append(cy * cw + cx)
return out
func encode_chunk(chunk_id: int) -> PackedByteArray:
var r := chunk_rect(chunk_id)
var out := PackedByteArray()
out.resize(r.size.x * r.size.y)
var i := 0
for ty in range(r.position.y, r.end.y):
for tx in range(r.position.x, r.end.x):
out[i] = tiles[ty * width + tx]
i += 1
return out
func apply_chunk(chunk_id: int, data: PackedByteArray) -> void:
var r := chunk_rect(chunk_id)
if data.size() != r.size.x * r.size.y:
return # malformed or from a different map; ignore rather than corrupt
var i := 0
for ty in range(r.position.y, r.end.y):
for tx in range(r.position.x, r.end.x):
tiles[ty * width + tx] = data[i]
i += 1