From 475280191cc1c2174f17aad7beee5fa05c621b6e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 20:32:32 +0100 Subject: [PATCH] fix(ui): render LOS boundary wall tiles through fog without marking explored (#585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- client/scripts/autoloads/fog_state.gd | 8 +++ client/scripts/autoloads/game_state.gd | 16 ++++-- client/tests/test_fog_shader.gd | 73 ++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/client/scripts/autoloads/fog_state.gd b/client/scripts/autoloads/fog_state.gd index 32252f5d5..7a87709d6 100644 --- a/client/scripts/autoloads/fog_state.gd +++ b/client/scripts/autoloads/fog_state.gd @@ -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) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 371d57042..bdccdce4a 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -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 diff --git a/client/tests/test_fog_shader.gd b/client/tests/test_fog_shader.gd index 6500b5ebb..e741b808e 100644 --- a/client/tests/test_fog_shader.gd +++ b/client/tests/test_fog_shader.gd @@ -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: