Files
transcience/src/sim/map_grid.gd
T
claudeandClaude Opus 5.5 855669a665
ci / verify (push) Successful in 52s
Fog lit by rays: shadows cut through tiles, and walls stay visible
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>
2026-10-05 10:07:33 +02:00

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