fix(client): fix fog system — blocky edges and zero explored visibility (#569)

Root cause: update_from_state() used visible_tiles (always empty in live
server mode) instead of visible_positions for bounds calculation. Bounds
never grew beyond 64x64, so tiles outside that area rendered as solid
unexplored black.

Fix: new _grow_bounds_from_positions() method reads visible_positions
(always populated from server snapshots). Shader fix: removed the
(explored < 0.01 && vis_raw < 0.01) guard that cut off the Gaussian
gradient at unexplored tile boundaries. Doubled blur step size for
D-066 compliant 6-8 tile soft gradient. Added debug_exploration mode
for diagnostic rendering of the exploration texture.

19 acceptance tests covering exploration persistence, bounds grow-only
invariant, gradient margin, Forward-only visibility writes, and
exploration data surviving texture resize.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 14:26:13 +01:00
co-authored by Claude Opus 4.6
parent 073c5a317c
commit ebc87d8e74
4 changed files with 613 additions and 46 deletions
+37 -6
View File
@@ -29,6 +29,11 @@ var _width: int = 1
var _height: int = 1
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
## Debug flag — when true, fog.gdshader renders raw exploration texture
## as colored overlay (green=visible, blue=explored, red=unexplored).
## Toggle via FogState.debug_exploration = true in the console.
var debug_exploration: bool = false
func _ready() -> void:
_resize(Rect2i(0, 0, 64, 64))
@@ -84,15 +89,17 @@ func update_from_state() -> void:
# Grow bounds to include newly visible tiles — never shrink, so explored
# tiles behind the player stay in the texture and render as deep fog
# instead of black. Exploration data is preserved across resizes.
var tiles := GameState.visible_tiles
if tiles.size() > 0:
var new_bounds := _grow_bounds(tiles)
# Use visible_positions (always populated from server snapshots) instead of
# visible_tiles, which stays empty in live server mode because the server
# sends tile_kind but game_state.gd's population check expects "type".
var positions: Dictionary = GameState.visible_positions
if positions.size() > 0:
var new_bounds := _grow_bounds_from_positions(positions)
if new_bounds != map_bounds:
_resize(new_bounds)
var ox: int = map_bounds.position.x
var oy: int = map_bounds.position.y
var positions: Dictionary = GameState.visible_positions
# TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED
@@ -130,6 +137,27 @@ func update_from_state() -> void:
_prev_visible = positions.duplicate()
func _grow_bounds_from_positions(positions: Dictionary) -> Rect2i:
## Compute bounds from visible_positions (Dictionary[Vector2i, bool]).
## Equivalent to _grow_bounds() but reads from the always-populated
## positions dict instead of the legacy visible_tiles Array.
var min_x := 999999
var min_y := 999999
var max_x := -999999
var max_y := -999999
for pos in positions:
min_x = mini(min_x, pos.x)
min_y = mini(min_y, pos.y)
max_x = maxi(max_x, pos.x)
max_y = maxi(max_y, pos.y)
if min_x > max_x:
return map_bounds
var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17)
if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
return tile_bounds
return map_bounds.merge(tile_bounds)
func _grow_bounds(tiles: Array) -> Rect2i:
## Compute bounds that include all currently visible tiles, merged with
## existing bounds so the texture only grows — never shrinks. Explored
@@ -148,8 +176,11 @@ func _grow_bounds(tiles: Array) -> Rect2i:
# Guard: all tiles invalid (no x/y) — sentinels would produce negative Rect2i
if min_x > max_x:
return map_bounds
# Margin for fog gradient bleed at edges
var tile_bounds := Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
# Margin for fog gradient bleed at edges.
# The Gaussian blur in fog.gdshader samples at 2-texel intervals across a 7x7 kernel,
# reaching ±6 tiles from the fragment position. Margin must be >= 8 to avoid
# clamping artifacts at texture boundaries.
var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17)
# Merge with existing bounds — grow only
if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
return tile_bounds
+1
View File
@@ -69,3 +69,4 @@ func update_fog() -> void:
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
_shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0)
_shader_mat.set_shader_parameter("debug_exploration", FogState.debug_exploration)