ajhahn.de
← Theria commits

Commit

Theria

feat: walls block line of sight in fog of war

ajhahnde · Jun 2026 · b8a65de35f721da7f4643116c34957215133fefb · parent: 994efe2 · view on GitHub →

modified CHANGELOG.md
@@ -52,8 +52,12 @@ protocol version.
reveals a radius around itself; the rest of the map sits under fog and the enemies in it are
hidden — they appear the moment they step into your team's vision and vanish when they leave. The
reveal is **authoritative**: an unseen enemy is never sent to a player's client, so it cannot be
read off the wire. *(First pass: vision is a plain radius — walls do not yet block line of sight —
and there is no remembered "last seen" terrain; both are follow-up slices.)*
read off the wire.
- **Vision blocked by walls**: the jungle rock walls now block line of sight — an enemy behind a wall
stays hidden even within your sight range, so a wall is real cover to gank from. Towers and the
nexus do not block sight (only terrain does). *(The fog overlay still lights a plain circle — the
shadow a wall casts into your vision is a visual follow-up; there is also no remembered "last seen"
terrain yet.)*
- A **minimap** in the bottom-right corner: a scaled plan of the arena with the lanes and river as a
backdrop and live dots for every unit — your team always, enemies only where your team has vision,
so it respects the same fog of war as the main view. Your own hero is ringed so it is easy to find.
modified src/sim/map_data.gd
@@ -162,6 +162,11 @@ const CAMP_FEATURE_CLEAR := 200.0 # no pocket rock on a lane or in the river
## reused by the per-tick collision, the nav grid, and the tests.
static var _obstacles: Array = []
## Lazily-built cache of the sight-blocking circles (the jungle walls only) — baked once and reused
## by the per-team vision pass. Separate from `_obstacles` because vision and collision block on
## different sets: a tower stops a body but not a sight line.
static var _vision_blockers: Array = []
## Reflection across the diagonal axis y = x — the map's mirror. An involution (its own inverse)
## that swaps the two bases, so team 1's geometry is team 0's mirrored.
@@ -265,6 +270,18 @@ static func _build_obstacles() -> Array:
return out
## The sight-blocking geometry for fog of war: the jungle rock walls and camp pockets only — a
## tower or the nexus blocks a body but never a sight line, so the structures are deliberately left
## out (you can see past your own buildings, as in the genre). Each a `{center, radius}` circle, the
## same wall footprints `obstacles` uses, mirrored across the axis so vision is team-fair, and baked
## once (the map is static). Read by Vision for line-of-sight occlusion; callers treat it read-only.
static func vision_blockers() -> Array:
if _vision_blockers.is_empty():
for p in jungle_wall_points():
_vision_blockers.append({"center": p, "radius": WALL_RADIUS})
return _vision_blockers
## The rock centres of the jungle walls and camp pockets — the blocker layout, shared by the sim
## (wrapped into WALL_RADIUS circles in `obstacles`) and the decor (which draws a boulder on each).
## Generated on team 0's side of the y = x axis and mirrored, so the layout is exactly symmetric.
modified src/sim/vision.gd
@@ -54,7 +54,8 @@ static func sight_sources(state: SimState, team: int) -> Array:
## 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 whose centre lies within the radius of one of the team's sight sources. Pure and
## 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)
@@ -65,7 +66,32 @@ static func visible_ids(state: SimState, team: int) -> Dictionary:
visible[id] = true
continue
for source in sources:
if entity.position.distance_to(source["center"]) <= source["radius"]:
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)
modified test/unit/test_map_data.gd
@@ -188,6 +188,29 @@ func test_obstacles_are_mirror_symmetric() -> void:
assert_true(found, "every obstacle has its y = x mirror in the set — collision is team-fair")
func test_vision_blockers_are_the_walls_only_and_mirror_symmetric() -> void:
# Sight is blocked by terrain, not buildings: the vision blockers are the jungle walls — every
# one a WALL_RADIUS circle on a wall point — and never a tower or the nexus.
var blockers := MapData.vision_blockers()
assert_eq(blockers.size(), MapData.jungle_wall_points().size(), "one sight blocker per wall point")
for b in blockers:
assert_eq(b["radius"], MapData.WALL_RADIUS, "a sight blocker is a wall-footprint circle")
for team in MapData.NEXUS_POSITIONS.size():
var structures := PackedVector2Array(MapData.tower_positions(team))
structures.append(MapData.nexus_for_team(team))
for s in structures:
for b in blockers:
assert_gt(b["center"].distance_to(s), 1.0, "a structure is not a sight blocker")
for b in blockers:
var mirrored: Vector2 = MapData.mirror(b["center"])
var found := false
for q in blockers:
if q["center"].distance_to(mirrored) < 0.01:
found = true
break
assert_true(found, "every sight blocker has its y = x mirror — vision is team-fair")
func test_spawns_and_camp_centres_are_walkable() -> void:
for team in MapData.NEXUS_POSITIONS.size():
assert_false(
modified test/unit/test_vision.gd
@@ -71,6 +71,43 @@ func test_sight_sources_lists_living_friendly_units_only() -> void:
assert_eq(sources[0]["radius"], Vision.HERO_SIGHT, "a hero's source carries the sight radius")
func test_a_wall_on_the_sight_line_hides_an_enemy_in_range() -> void:
# Locate a real sight blocker and straddle it: a friendly source one side, the enemy the other,
# close enough to be in range — the wall sitting between them must hide the enemy (the gank).
var blockers := MapData.vision_blockers()
assert_gt(blockers.size(), 0, "the map has sight-blocking walls")
var wall: Dictionary = blockers[0]
var center: Vector2 = wall["center"]
var radius: float = wall["radius"]
var sim := _world()
sim.add_hero(0, center - Vector2.RIGHT * (radius + 30.0), 320.0)
var enemy := sim.add_entity(1, center + Vector2.RIGHT * (radius + 30.0), 320.0, 100)
assert_false(Vision.visible_ids(sim.state, 0).has(enemy), "a wall on the sight line hides it")
func test_a_clear_sight_line_sees_an_enemy_in_range() -> void:
# Near a base fountain the walls keep their distance (WALL_SPAWN_CLEAR), so the sight line is
# open — an in-range enemy with no wall between is seen, the counterpart to the occlusion above.
var sim := _world()
var base := MapData.spawn_for_team(0)
sim.add_hero(0, base, 320.0)
var enemy := sim.add_entity(1, base - base.normalized() * 150.0, 320.0, 100)
assert_true(Vision.visible_ids(sim.state, 0).has(enemy), "a clear in-range enemy is seen")
func test_visible_ids_stays_cheap_over_a_heavy_world() -> void:
# A slideshow tripwire, not a microbenchmark: the LOS occlusion scans the wall set, and the pass
# runs a few times per tick (snapshot filter + render hide + minimap), so a full world must
# resolve well inside the tick budget. The auto-pathing slice taught this lesson the hard way.
var sim := _heavy_world()
var start := Time.get_ticks_usec()
for _i in 200:
Vision.visible_ids(sim.state, 0)
var per_call_us := float(Time.get_ticks_usec() - start) / 200.0
print("visible_ids per call over a heavy world: %.1f us" % per_call_us)
assert_lt(per_call_us, 2000.0, "visible_ids over a heavy world stays well under 2 ms/call")
func test_vision_is_team_fair_under_the_map_mirror() -> void:
# The same encounter mirrored across the y = x axis and with the teams swapped must resolve the
# same way — neither team sees farther, so fog never favours a side.
@@ -90,3 +127,15 @@ func test_vision_is_team_fair_under_the_map_mirror() -> void:
Vision.visible_ids(b.state, 1).has(enemy_b),
"the mirrored encounter resolves identically for the swapped team",
)
## A full mid-match world for the perf guard: both teams' structures, three heroes a side, and the
## opening creep waves on every lane (the first wave spawns on tick 0, so one step seeds it).
func _heavy_world() -> SimCore:
var sim := SimCore.new()
sim.spawn_structures()
for team in 2:
for i in 3:
sim.add_hero(team, MapData.squad_spawn(team, i, 3), 320.0)
sim.step({})
return sim