ci / verify (push) Successful in 52s
The fog asked one question per tile -- can I see its centre? -- and drew or skipped the whole tile on the answer. Every shadow was a staircase, tiles snapped in and out as you walked, and a wall beside you vanished whenever its centre happened to sit behind its neighbour. Visibility casts rays from the eye instead: a fixed sweep, plus two aimed either side of every wall corner, which is what makes each shadow edge a straight line rather than a smear between rays. The view now draws terrain and actors first and then darkness over whatever the rays did not reach, so a shadow runs straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets are a child node and are still drawn over the dark; telegraphs are drawn after it, as they must be. "A bit less strict" is three specific things. A wall face you can see is lit 26 units in, measured perpendicular to the face; shadows end in a short fade rather than a line; and sight fades over its last 64 units rather than stopping at a circle. Still hard fog -- nothing remembered. The first version measured that depth along the ray, and a screenshot caught what no test had: a wall you look along got a sliver, so the wall beside you in a tunnel looked missing -- the old fog's own complaint. Perpendicular depth fixes it, and light stops the instant a ray steps back into the open, so it never rounds a corner, never reaches the far side of a one-tile wall, and never leaves a stepped wall through its other face. Two tiles touching only at a corner are closed. About half a millisecond a frame. Measured against the old fog in the same scene: hub 68 fps against 62, dungeon 240 against 240. The hub's own low rate predates this. MapGrid gains a revision counter so the corner list is rebuilt only when the map changes or the eye crosses a tile. screenshot.tscn now captures a dungeon, a tunnel and the tunnel with F1's overlay, which draws where each ray stopped -- and makes SHOT_DIR rather than failing every capture when it does not exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
11 KiB
GDScript
321 lines
11 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
|
|
## Bumped by every write, so something that derives data from the tiles can tell
|
|
## whether it is stale without comparing them. The client's fog extracts wall
|
|
## corners from the map and would otherwise have to redo it every frame, or miss
|
|
## a chunk arriving.
|
|
var revision: int = 0
|
|
|
|
|
|
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)
|
|
revision += 1
|
|
|
|
|
|
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
|
|
revision += 1
|
|
|
|
|
|
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
|
|
revision += 1
|
|
|
|
|
|
## 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, from tile to tile.
|
|
## Used for aggro on the server.
|
|
##
|
|
## It used to drive the client's fog as well, which is why the two once had to
|
|
## agree. The fog is exact geometry now (see Visibility), so this is the
|
|
## server's question alone -- and it is coarser than what the player sees: its
|
|
## diagonal steps pass between two tiles that touch only at a corner, which
|
|
## the fog treats as closed.
|
|
##
|
|
## [param include_end] is false here: the endpoints themselves never block, so
|
|
## standing in a doorway, or shooting at something embedded in a wall, still
|
|
## resolves.
|
|
func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
|
|
return _walk_clear(from, to, BLOCKS_SIGHT, false)
|
|
|
|
|
|
## Whether a bullet could travel from [param from] to [param to] without
|
|
## crossing geometry.
|
|
##
|
|
## Needed because a muzzle offset is a teleport: [BulletPool] samples position
|
|
## once per tick and never sees the jump from a player's centre out to where the
|
|
## shot is actually born, so anything that places a bullet away from its shooter
|
|
## has to ask this first. Unlike sight, the DESTINATION counts -- a bullet born
|
|
## inside a wall is exactly the case being ruled out.
|
|
func bullet_path_clear(from: Vector2, to: Vector2) -> bool:
|
|
return _walk_clear(from, to, BLOCKS_BULLET, true)
|
|
|
|
|
|
## Shared Bresenham walk behind both tests above, so the two can never disagree
|
|
## about which tiles a straight line crosses. [param flags] is one of the
|
|
## BLOCKS_* tables; [param include_end] decides whether the destination tile is
|
|
## allowed to stop the walk.
|
|
func _walk_clear(from: Vector2, to: Vector2, flags: Array, include_end: bool) -> 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
|
|
var at_end := x == b.x and y == b.y
|
|
if at_end and not include_end:
|
|
return true
|
|
if not (x == a.x and y == a.y) and bool(flags[at(x, y)]):
|
|
return false
|
|
if at_end:
|
|
return true
|
|
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
|
|
revision += 1
|