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