fix(ui): render LOS boundary wall tiles through fog without marking explored (#585)

- game_state.gd: add boundary_positions Dictionary field; BoundaryWall tiles from
  visible_tiles go to boundary_positions instead of visible_positions — rendered by
  tile_renderer but not tracked as explored fog memory
- fog_state.gd: update_from_state() writes VIS_FORWARD for boundary_positions so fog
  lifts over margin wall content; boundary tiles excluded from exploration step so they
  don't persist as EXP_EXPLORED when player turns away
- tile_renderer.gd: no changes needed — renders all visible_tiles by type, sector-agnostic
- test_fog_shader.gd: 4 new tests — boundary excluded from visible_positions, tracked in
  boundary_positions, cleared each snapshot, fog lifts to VIS_FORWARD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 20:32:32 +01:00
co-authored by Claude Sonnet 4.6
parent c3abf32185
commit 475280191c
3 changed files with 93 additions and 4 deletions
+8
View File
@@ -161,6 +161,14 @@ func update_from_state() -> void:
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
_vis_bytes[py * _width + px] = VIS_FORWARD
# #585: BoundaryWall margin tiles — fog lifts so wall content composites correctly,
# but NOT in visible_positions so they don't persist as explored memory.
for pos in GameState.boundary_positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
_vis_bytes[py * _width + px] = VIS_FORWARD
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture.update(_vis_image)
+12 -4
View File
@@ -18,7 +18,8 @@ var current_tick: int = 0
var player_position: Vector2 = Vector2.ZERO
var visible_entities: Array = []
var visible_tiles: Array = []
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored
# v2 fields (D-015, D-031)
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
@@ -347,17 +348,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
# Derives visible_positions when not explicitly provided (real server mode).
# #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored.
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
visibility_sectors.clear()
var has_explicit_positions := snapshot.has("visible_positions")
if not has_explicit_positions:
visible_positions.clear()
boundary_positions.clear()
for vtile in snapshot.visible_tiles:
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
continue
var pos := Vector2i(vtile.x, vtile.y)
var vis_sector: String = vtile.get("visibility", "")
if vtile.has("visibility"):
visibility_sectors[pos] = vtile.visibility
if not has_explicit_positions:
visibility_sectors[pos] = vis_sector
# #585: BoundaryWall tiles are margin tiles visible through fog but not persistently
# explored — they don't update the player's exploration memory when they leave LOS.
if vis_sector == "BoundaryWall":
boundary_positions[pos] = true
elif not has_explicit_positions:
visible_positions[pos] = true
+73
View File
@@ -268,6 +268,79 @@ func test_game_state_visible_positions_cleared_on_new_snapshot() -> void:
assert_that(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
# -- #585: BoundaryWall tiles — visible in fog, not persistently explored ------
func test_boundary_wall_tiles_not_in_visible_positions() -> void:
## #585: BoundaryWall margin tiles must NOT enter visible_positions.
## They are rendered via tile_renderer (from visible_tiles) but must not
## update the player's fog exploration memory.
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall"},
],
})
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true()
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_false()
assert_that(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
func test_boundary_wall_tiles_in_visibility_sectors() -> void:
## BoundaryWall visibility sector is still tracked in visibility_sectors
## (for potential future use — wall coloring, etc.)
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [
{"x": 3, "y": 3, "z": 0, "visibility": "BoundaryWall"},
],
})
assert_that(GameState.visibility_sectors.has(Vector2i(3, 3))).is_true()
assert_that(GameState.visibility_sectors[Vector2i(3, 3)]).is_equal("BoundaryWall")
func test_boundary_positions_cleared_on_new_snapshot() -> void:
## BoundaryWall positions are cleared each snapshot so stale walls don't persist.
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [{"x": 7, "y": 7, "z": 0, "visibility": "BoundaryWall"}],
})
assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_true()
GameState.apply_snapshot({
"tick": 2,
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
})
assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_false()
assert_that(GameState.boundary_positions.size()).is_equal(0)
func test_boundary_wall_fog_vis_forward() -> void:
## #585: BoundaryWall tiles must lift fog (VIS_FORWARD = 255) so wall content composites.
## visible_positions excludes boundary tiles; fog_state writes vis bytes for them separately.
## Reads _vis_bytes directly (packed byte array) to avoid ImageTexture.get_image() lag.
var fog_state = _get_fog_state()
if fog_state == null:
return
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [
{"x": 0, "y": 0, "z": 0, "visibility": "Forward"}, # normal LOS tile
{"x": 1, "y": 0, "z": 0, "visibility": "BoundaryWall"}, # margin tile
],
})
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var vis: PackedByteArray = fog_state._vis_bytes
var normal_idx: int = (0 - oy) * w + (0 - ox)
var boundary_idx: int = (0 - oy) * w + (1 - ox)
assert_int(vis[normal_idx]).is_equal(FogState.VIS_FORWARD) # normal tile: VIS_FORWARD
assert_int(vis[boundary_idx]).is_equal(FogState.VIS_FORWARD) # boundary also fog-lifted
GameState.visible_positions.clear()
GameState.boundary_positions.clear()
# -- Z-layer compliance (D-049) -----------------------------------------------
func test_fog_overlay_z_layer() -> void: