GDScript 98 lines
class_name Vision
extends RefCounted
## Per-team fog-of-war vision over the authoritative world.
##
## A team sees its own units always, plus any unit standing within the sight radius of one of
## those units — a plain radius reveal, no terrain occlusion (a wall does not block sight in v1;
## that is a later slice that can read MapData.obstacles()). Pure data over a SimState, like
## MapData and the simulation core: no engine, render, or global-state coupling, so it is shared
## by the server's snapshot filter, the client's render, and the headless tests, and replays
## identically.
##
## The server runs `visible_ids` per receiving team and only ever sends that team the entities it
## can see (NetProtocol.encode_snapshot's filter), so an enemy in fog never crosses the wire — the
## fog is authoritative, not a client dim a maphack could peel back. The renderer feeds
## `sight_sources` to the fog overlay so the lit reveal matches exactly which units are sent.
## How far each kind of unit sees, in world units. A hero scouts widest; a tower/nexus holds a
## fixed ward over its approach; a lane creep lights only its immediate front. Tuned lighter than a
## full MOBA's wards (there are none yet) so map control still rewards moving a hero up.
const HERO_SIGHT := 1400.0
const CREEP_SIGHT := 900.0
const STRUCTURE_SIGHT := 1300.0
## How far `entity` sees, or 0 for a unit that grants no vision (a pure mover, or a downed hero —
## a dead unit's ward goes dark until it respawns). The one place the per-kind radii are resolved,
## read by both `sight_sources` and `visible_ids`.
static func sight_radius(entity: SimEntity) -> float:
if entity.is_dead():
return 0.0
if entity.is_hero:
return HERO_SIGHT
if entity.is_creep:
return CREEP_SIGHT
if entity.is_structure:
return STRUCTURE_SIGHT
return 0.0
## The reveal set for `team`: one `{center, radius}` per living friendly unit that grants vision,
## in entity insertion order. Shared by the fog render (the lit circles) and `visible_ids` (the
## membership test), so what is drawn lit is exactly what is sent.
static func sight_sources(state: SimState, team: int) -> Array:
var sources: Array = []
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.team != team:
continue
var radius := sight_radius(entity)
if radius > 0.0:
sources.append({"center": entity.position, "radius": radius})
return sources
## The ids `team` can see, as an id->true set for O(1) membership: every own-team entity always
## (you never lose sight of your own units, even a downed hero on the respawn clock), plus any
## entity that lies within the radius of one of the team's sight sources **and** has a clear sight
## line to it — a wall between the source and the unit hides it even in range (real ganks). Pure and
## insertion-ordered, so the server filters every client's snapshot deterministically.
static func visible_ids(state: SimState, team: int) -> Dictionary:
var sources := sight_sources(state, team)
var visible: Dictionary = {}
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.team == team:
visible[id] = true
continue
for source in sources:
var in_range: bool = entity.position.distance_to(source["center"]) <= source["radius"]
if in_range and _los_clear(source["center"], entity.position):
visible[id] = true
break
return visible
## Whether the straight sight line from `a` to `b` is clear of every vision blocker — the ray a
## sight source casts to a unit. Blocked when the segment passes within a blocker's radius of its
## centre (the ray crosses the rock). Walls only block sight (MapData.vision_blockers); structures
## do not. The same segment-vs-circle math the collision uses, so a wall that stops a body stops a
## sight line too.
static func _los_clear(a: Vector2, b: Vector2) -> bool:
for blocker in MapData.vision_blockers():
if _point_to_segment_sq(blocker["center"], a, b) < blocker["radius"] * blocker["radius"]:
return false
return true
## The squared distance from point `p` to the segment `a`–`b`. Squared to keep the per-blocker LOS
## test free of a square root — the radius is squared at the call site to compare. Projects `p` onto
## the segment, clamped to the endpoints.
static func _point_to_segment_sq(p: Vector2, a: Vector2, b: Vector2) -> float:
var ab := b - a
var len_sq := ab.length_squared()
if len_sq < 0.0001:
return p.distance_squared_to(a)
var t := clampf((p - a).dot(ab) / len_sq, 0.0, 1.0)
return p.distance_squared_to(a + ab * t)