class_name BrogueFOV extends RefCounted # Symmetric recursive shadowcasting, tile-accurate FOV for 2D grids. # # Port of the standard 8-octant algorithm (Bjoern Bergstrom / Adam Milazzo's # symmetric variant). In ~100 lines of pure GDScript. # # Properties: # - Tile-accurate: every cell is either fully visible or not visible. # - Symmetric: if A sees B, B sees A. Critical for fair AI awareness. # - Circular radius clipping (euclidean distance). # - Opaque = walls, T_NOTHING, and unbridged liquid (lava/chasm/brimstone). # Water is transparent (you can see across water). # # Usage: # var vis := BrogueFOV.compute(grid, Vector2i(39, 14), 8) # for y in grid["height"]: # for x in grid["width"]: # if vis[y * grid["width"] + x] == 1: # draw_cell(x, y) # Must match src/gen/grid.h:terrain_t const T_NOTHING := 0 const T_FLOOR := 1 const T_WALL := 2 const T_DOOR := 3 const T_CORRIDOR := 4 const T_LIQUID := 5 const T_BRIDGE := 6 const T_STAIRS_UP := 7 const T_STAIRS_DOWN := 8 const L_WATER := 1 # Octant transforms: each octant maps (row, col) of the shadowcasting loop # to grid deltas. 8 octants cover all directions symmetrically. const OCTANTS := [ Vector4i( 1, 0, 0, 1), Vector4i( 0, 1, 1, 0), Vector4i( 0, -1, 1, 0), Vector4i(-1, 0, 0, 1), Vector4i(-1, 0, 0, -1), Vector4i( 0, -1, -1, 0), Vector4i( 0, 1, -1, 0), Vector4i( 1, 0, 0, -1), ] # Returns PackedByteArray of width*height, row-major, 1 = visible. static func compute(grid: Dictionary, origin: Vector2i, radius: int) -> PackedByteArray: var w: int = grid["width"] var h: int = grid["height"] var out := PackedByteArray() out.resize(w * h) for i in range(w * h): out[i] = 0 if origin.x < 0 or origin.y < 0 or origin.x >= w or origin.y >= h: return out # Origin is always visible. out[origin.y * w + origin.x] = 1 var terrain: PackedByteArray = grid["terrain"] var liquid: PackedByteArray = grid["liquid"] for oct in OCTANTS: _cast_octant(out, terrain, liquid, origin, radius, w, h, 1, 1.0, 0.0, oct) return out static func _is_opaque(terrain: PackedByteArray, liquid: PackedByteArray, x: int, y: int, w: int) -> bool: var idx := y * w + x var t: int = terrain[idx] if t == T_WALL or t == T_NOTHING: return true if t == T_LIQUID: # Water is transparent; lava / chasm / brimstone are opaque. return liquid[idx] != L_WATER return false static func _cast_octant(out: PackedByteArray, terrain: PackedByteArray, liquid: PackedByteArray, origin: Vector2i, radius: int, w: int, h: int, row: int, start_slope: float, end_slope: float, oct: Vector4i) -> void: if start_slope < end_slope: return var r2: int = radius * radius var new_start := start_slope var blocked := false for i in range(row, radius + 1): if blocked: break for dy in range(-i, 1): var dx := -i while dx <= 0: var tx: int = origin.x + dx * oct.x + dy * oct.y var ty: int = origin.y + dx * oct.z + dy * oct.w var l_slope := (dx - 0.5) / (dy + 0.5) var r_slope := (dx + 0.5) / (dy - 0.5) if start_slope < r_slope: dx += 1 continue if end_slope > l_slope: break if tx >= 0 and tx < w and ty >= 0 and ty < h: if dx * dx + dy * dy <= r2: out[ty * w + tx] = 1 if blocked: if _is_opaque(terrain, liquid, tx, ty, w): new_start = r_slope else: blocked = false start_slope = new_start else: if _is_opaque(terrain, liquid, tx, ty, w) and i < radius: blocked = true _cast_octant(out, terrain, liquid, origin, radius, w, h, i + 1, start_slope, l_slope, oct) new_start = r_slope dx += 1