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

class DungeonCrawler:
    def __init__(self, width=21, height=21):
        self.width = width
        self.height = height
        self.grid = [['#' for _ in range(width)] for _ in range(height)]
        self.rooms = []
        self.player_pos = (1, 1)
        self.exit_pos = (width - 2, height - 2)
        self.explored = [[False for _ in range(width)] for _ in range(height)]
        self.visible = [[False for _ in range(width)] for _ in range(height)]
        self.sight_radius = 5
        self.generate_dungeon()
        self.update_visibility()

    def generate_dungeon(self):
        """Generate rooms and connect them with corridors."""
        num_rooms = random.randint(5, 8)
        min_size = 3
        max_size = 7

        for _ in range(num_rooms):
            w = random.randint(min_size, max_size)
            h = random.randint(min_size, max_size)
            x = random.randint(1, self.width - w - 1)
            y = random.randint(1, self.height - h - 1)

            # Check overlap with existing rooms
            overlap = False
            for room in self.rooms:
                rx, ry, rw, rh = room
                if not (x + w < rx - 1 or x > rx + rw + 1 or y + h < ry - 1 or y > ry + rh + 1):
                    overlap = True
                    break

            if not overlap:
                self.rooms.append((x, y, w, h))
                for i in range(y, y + h):
                    for j in range(x, x + w):
                        self.grid[i][j] = '.'

        # Connect rooms with corridors
        for i in range(1, len(self.rooms)):
            prev_room = self.rooms[i - 1]
            curr_room = self.rooms[i]

            # Get centers
            prev_cx = prev_room[0] + prev_room[2] // 2
            prev_cy = prev_room[1] + prev_room[3] // 2
            curr_cx = curr_room[0] + curr_room[2] // 2
            curr_cy = curr_room[1] + curr_room[3] // 2

            # Create L-shaped corridor
            self._carve_corridor(prev_cx, prev_cy, curr_cx, curr_cy)

        # Place player in first room center
        if self.rooms:
            first_room = self.rooms[0]
            self.player_pos = (
                first_room[0] + first_room[2] // 2,
                first_room[1] + first_room[3] // 2
            )

        # Place exit in last room center
        if self.rooms:
            last_room = self.rooms[-1]
            self.exit_pos = (
                last_room[0] + last_room[2] // 2,
                last_room[1] + last_room[3] // 2
            )

    def _carve_corridor(self, x1, y1, x2, y2):
        """Carve an L-shaped corridor between two points."""
        # Horizontal then vertical
        for x in range(min(x1, x2), max(x1, x2) + 1):
            if 0 < x < self.width - 1 and 0 < y1 < self.height - 1:
                self.grid[y1][x] = '.'
        for y in range(min(y1, y2), max(y1, y2) + 1):
            if 0 < x2 < self.width - 1 and 0 < y < self.height - 1:
                self.grid[y][x2] = '.'

    def update_visibility(self):
        """Update visible tiles using raycasting within sight radius."""
        self.visible = [[False for _ in range(self.width)] for _ in range(self.height)]
        px, py = self.player_pos

        # Mark tiles within radius as visible if line of sight is clear
        for dy in range(-self.sight_radius, self.sight_radius + 1):
            for dx in range(-self.sight_radius, self.sight_radius + 1):
                tx, ty = px + dx, py + dy
                if 0 <= tx < self.width and 0 <= ty < self.height:
                    dist = math.sqrt(dx * dx + dy * dy)
                    if dist <= self.sight_radius:
                        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, y1, x2, y2):
        """Check if there is a clear line of sight between two points using Bresenham's algorithm."""
        dx = abs(x2 - x1)
        dy = abs(y2 - y1)
        sx = 1 if x1 < x2 else -1
        sy = 1 if y1 < y2 else -1
        err = dx - dy

        x, y = x1, y1
        while True:
            if x == x2 and y == y2:
                return True
            # Check if current cell is a wall (except start)
            if (x, y) != (x1, y1) and self.grid[y][x] == '#':
                return False
            e2 = 2 * err
            if e2 > -dy:
                err -= dy
                x += sx
            if e2 < dx:
                err += dx
                y += sy
            if x < 0 or x >= self.width or y < 0 or y >= self.height:
                return False

    def move_player(self, dx, dy):
        """Move player if target is walkable."""
        nx, ny = self.player_pos[0] + dx, self.player_pos[1] + dy
        if 0 <= nx < self.width and 0 <= ny < self.height and self.grid[ny][nx] == '.':
            self.player_pos = (nx, ny)
            self.update_visibility()
            return True
        return False

    def render(self):
        """Render the dungeon to terminal."""
        lines = []
        for y in range(self.height):
            line = ""
            for x in range(self.width):
                if (x, y) == self.player_pos:
                    line += "@"
                elif (x, y) == self.exit_pos:
                    line += "E"
                elif self.visible[y][x]:
                    line += self.grid[y][x]
                elif self.explored[y][x]: