```python
import random
import math
from collections import deque

class DungeonCrawler:
    def __init__(self, width=21, height=21, fov_radius=5):
        self.width = width
        self.height = height
        self.fov_radius = fov_radius
        self.grid = [['#' for _ in range(width)] for _ in range(height)]
        self.rooms = []
        self.player_pos = None
        self.exit_pos = None
        self.visible = [[False for _ in range(width)] for _ in range(height)]
        self.explored = [[False for _ in range(width)] for _ in range(height)]
        
    def generate_dungeon(self):
        """Generate a dungeon using random room placement and corridor carving."""
        # Clear grid
        for y in range(self.height):
            for x in range(self.width):
                self.grid[y][x] = '#'
        self.rooms = []
        
        # Generate rooms
        num_rooms = random.randint(5, 8)
        for _ in range(num_rooms):
            room_w = random.randint(3, 7)
            room_h = random.randint(3, 7)
            room_x = random.randint(1, self.width - room_w - 1)
            room_y = random.randint(1, self.height - room_h - 1)
            
            # Check if room overlaps with existing rooms
            new_room = (room_x, room_y, room_x + room_w, room_y + room_h)
            overlap = False
            for existing_room in self.rooms:
                if self._rooms_overlap(new_room, existing_room):
                    overlap = True
                    break
            
            if not overlap:
                self._carve_room(room_x, room_y, room_w, room_h)
                self.rooms.append(new_room)
        
        # Connect rooms with corridors
        for i in range(len(self.rooms) - 1):
            self._connect_rooms(self.rooms[i], self.rooms[i + 1])
        
        # Place player in first room
        if self.rooms:
            first_room = self.rooms[0]
            center_x = (first_room[0] + first_room[2]) // 2
            center_y = (first_room[1] + first_room[3]) // 2
            self.player_pos = (center_x, center_y)
            self.grid[center_y][center_x] = '@'
        
        # Place exit in last room
        if self.rooms:
            last_room = self.rooms[-1]
            center_x = (last_room[0] + last_room[2]) // 2
            center_y = (last_room[1] + last_room[3]) // 2
            self.exit_pos = (center_x, center_y)
            if self.grid[center_y][center_x] != '@':
                self.grid[center_y][center_x] = 'E'
        
        # Initial visibility
        self._update_visibility()
    
    def _rooms_overlap(self, room1, room2):
        """Check if two rooms overlap."""
        x1_min, y1_min, x1_max, y1_max = room1
        x2_min, y2_min, x2_max, y2_max = room2
        
        return not (x1_max <= x2_min or x2_max <= x1_min or 
                    y1_max <= y2_min or y2_max <= y1_min)
    
    def _carve_room(self, x, y, w, h):
        """Carve a room into the grid."""
        for row in range(y, y + h):
            for col in range(x, x + w):
                if 0 < row < self.height - 1 and 0 < col < self.width - 1:
                    self.grid[row][col] = '.'
    
    def _connect_rooms(self, room1, room2):
        """Connect two rooms with an L-shaped corridor."""
        x1 = (room1[0] + room1[2]) // 2
        y1 = (room1[1] + room1[3]) // 2
        x2 = (room2[0] + room2[2]) // 2
        y2 = (room2[1] + room2[3]) // 2
        
        # Randomly choose horizontal first or vertical first
        if random.random() < 0.5:
            self._carve_horizontal(x1, x2, y1)
            self._carve_vertical(y1, y2, x2)
        else:
            self._carve_vertical(y1, y2, x1)
            self._carve_horizontal(x1, x2, y2)
    
    def _carve_horizontal(self, x1, x2, y):
        """Carve a horizontal corridor."""
        for x in range(min(x1, x2), max(x1, x2) + 1):
            if 0 < y < self.height - 1 and 0 < x < self.width - 1:
                if self.grid[y][x] == '#':
                    self.grid[y][x] = '.'
    
    def _carve_vertical(self, y1, y2, x):
        """Carve a vertical corridor."""
        for y in range(min(y1, y2), max(y1, y2) + 1):
            if 0 < y < self.height - 1 and 0 < x < self.width - 1:
                if self.grid[y][x] == '#':
                    self.grid[y][x] = '.'
    
    def _update_visibility(self):
        """Update visible and explored tiles using raycasting."""
        # Reset visibility
        for y in range(self.height):
            for x in range(self.width):
                self.visible[y][x] = False
        
        if self.player_pos is None:
            return
        
        px, py = self.player_pos
        
        # Mark player position as visible
        self.visible[py][px] = True
        self.explored[py][px] = True
        
        # Raycast to all tiles within radius
        for dy in range(-self.fov_radius, self.fov_radius + 1):
            for dx in range(-self.fov_radius, self.fov_radius + 1):
                if dx == 0 and dy == 0:
                    continue
                
                tx, ty = px + dx, py + dy
                
                # Check bounds
                if 0 <= tx < self.width and 0 <= ty < self.height:
                    # Check if within radius
                    if math.sqrt(dx * dx + dy * dy) <= self.fov_radius:
                        # Check line of sight
                        if self._has_line_of_sight(px, py, tx, ty):
                            self.visible[ty][tx] = True
                            self.explored[ty][tx] = True
    
    def _has_line_of_sight(self, x1, y