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:
@@ -0,0 +1,280 @@
|
||||
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
|
||||
Reference in New Issue
Block a user