ajhahn.de
← Theria
GDScript 75 lines
class_name FogOverlay
extends RefCounted
## The fog-of-war sheet drawn over the playfield: a dark plane spanning the arena that dims
## everywhere the player's team cannot see, cleared in a circle around each of the team's sight
## sources. It is presentation only — which enemies are hidden, and which never reach a remote
## client at all, is decided authoritatively by Vision and the snapshot filter; this just tints
## the unseen ground so the fog reads. Built once with the scene (skipped on a headless run, which
## has no display) and fed the live reveal circles each tick by `main.gd`.
##
## The reveal set comes straight from Vision.sight_sources, the same circles the server's snapshot
## filter uses, so the lit ground matches exactly which units are sent — a unit appears the instant
## its position enters a lit circle.

const FOG_SHADER: Shader = preload("res://src/client/fog.gdshader")

## Must match the fog shader's MAX_SOURCES. A 3v3 team fields well under this — up to three heroes,
## a dozen lane creeps, and five structures, ~20 sources — so the cap only ever clips a pathological
## case, in which the surplus sources simply go undrawn (a touch more fog), never an error.
const MAX_SOURCES := 64

## The sheet's height above the ground: above MapView's flat lane/river/bridge decor so the field
## dims under it, but well below the 3D bodies (heroes, towers) so a unit standing in a lit circle
## rises in front of the fog rather than being tinted by it.
const FOG_Y := 10.0

var _material: ShaderMaterial


## Builds the fog plane under `parent` and returns the overlay holding its material. Call once,
## after the ground and map decor exist; the plane covers the whole arena at a fixed lift.
static func build(parent: Node3D) -> FogOverlay:
	var fog := FogOverlay.new()
	fog._material = ShaderMaterial.new()
	fog._material.shader = FOG_SHADER
	var mesh := MeshInstance3D.new()
	var plane := PlaneMesh.new()
	plane.size = MapData.BOUNDS.size
	mesh.mesh = plane
	var center := MapData.BOUNDS.get_center()
	mesh.position = Vector3(center.x, FOG_Y, center.y)
	mesh.material_override = fog._material
	# Span the whole map, so a reveal circle near the rim is never clipped by the camera frustum
	# culling the plane when the hero is across the arena.
	mesh.extra_cull_margin = MapData.BOUNDS.size.length()
	parent.add_child(mesh)
	return fog


## Applies fog of war to the drawn world for `team`: dims the ground that team cannot see (every
## mode) and hides the enemy bodies standing in that fog (`hide_fogged`, set only with local
## authority — a pure CLIENT already receives a snapshot filtered to its team, so every entity it
## holds is one it can see). `views` is the renderer's id->view pool. Friendlies are always visible,
## so the hide pass only ever drops enemies, layered over the renderer's own dead-hero hide.
func apply(state: SimState, team: int, views: Dictionary, hide_fogged: bool) -> void:
	if hide_fogged:
		var visible := Vision.visible_ids(state, team)
		for id in state.entities:
			if not visible.has(id):
				(views[id]["root"] as Node3D).visible = false
	update(Vision.sight_sources(state, team))


## Feeds this tick's reveal circles to the shader: each `{center: Vector2, radius: float}` from
## Vision.sight_sources, packed as (center.x, center.y, radius, 0) in world units. Capped at
## MAX_SOURCES; an empty set leaves the field clear (no vision data yet).
func update(sources: Array) -> void:
	var packed := PackedVector4Array()
	for source in sources:
		if packed.size() >= MAX_SOURCES:
			break
		var center: Vector2 = source["center"]
		packed.append(Vector4(center.x, center.y, source["radius"], 0.0))
	_material.set_shader_parameter("fog_sources", packed)
	_material.set_shader_parameter("source_count", packed.size())