← 블로그 목록 2025.02.10

Canvas로 시야각과 안개 전쟁 구현하기

FPS 게임에서 시야 제한은 긴장감의 핵심입니다. 모든 것이 다 보이면 기습의 재미가 사라지고, 아무것도 안 보이면 답답합니다. Dogkov에서는 플레이어 전방 약 90도의 시야각만 밝게 보이고, 나머지는 어둠에 덮이는 안개 전쟁(Fog of War) 시스템을 구현했습니다.

접근 방식: 마스크 레이어

Canvas 2D에서 안개 전쟁을 구현하는 가장 실용적인 방법은 별도의 마스크 레이어를 사용하는 것입니다. 게임 화면을 먼저 그린 다음, 그 위에 "시야 밖은 검은색, 시야 안은 투명"한 마스크를 덮습니다.

핵심 API는 globalCompositeOperation입니다:

function drawFogOfWar() {
    // 오프스크린 캔버스에 마스크 생성
    fogCtx.clearRect(0, 0, fogCanvas.width, fogCanvas.height);

    // 1단계: 전체를 검은색으로 채움
    fogCtx.fillStyle = 'rgba(0, 0, 0, 0.92)';
    fogCtx.fillRect(0, 0, fogCanvas.width, fogCanvas.height);

    // 2단계: 시야 영역을 "지움" (destination-out)
    fogCtx.globalCompositeOperation = 'destination-out';

    // 시야 원뿔 그리기
    drawVisionCone(fogCtx, player);

    // 3단계: 복원
    fogCtx.globalCompositeOperation = 'source-over';

    // 메인 캔버스에 마스크 합성
    ctx.drawImage(fogCanvas, 0, 0);
}

destination-out 모드에서 그린 영역은 기존 콘텐츠를 "지우는" 효과를 냅니다. 검은 마스크 위에 시야 원뿔을 destination-out으로 그리면, 해당 영역만 투명해져서 게임 화면이 보이게 됩니다.

시야 원뿔 (Vision Cone)

Dogkov의 시야는 플레이어가 마우스로 조준하는 방향을 중심으로 약 90도(±45도) 원뿔 형태입니다:

function drawVisionCone(ctx, player) {
    const visionRange = 500;    // 시야 거리
    const visionAngle = Math.PI / 2;  // 90도

    // 그라데이션으로 자연스러운 감쇠 효과
    const gradient = ctx.createRadialGradient(
        player.x, player.y, 0,
        player.x, player.y, visionRange
    );
    gradient.addColorStop(0, 'rgba(255,255,255,1)');
    gradient.addColorStop(0.7, 'rgba(255,255,255,0.8)');
    gradient.addColorStop(1, 'rgba(255,255,255,0)');

    ctx.fillStyle = gradient;

    // 원뿔 경로
    ctx.beginPath();
    ctx.moveTo(player.x, player.y);
    ctx.arc(
        player.x, player.y,
        visionRange,
        player.angle - visionAngle / 2,
        player.angle + visionAngle / 2
    );
    ctx.closePath();
    ctx.fill();

    // 캐릭터 주변 약간의 기본 시야 (360도, 작은 범위)
    const ambientGradient = ctx.createRadialGradient(
        player.x, player.y, 0,
        player.x, player.y, 80
    );
    ambientGradient.addColorStop(0, 'rgba(255,255,255,1)');
    ambientGradient.addColorStop(1, 'rgba(255,255,255,0)');

    ctx.fillStyle = ambientGradient;
    ctx.beginPath();
    ctx.arc(player.x, player.y, 80, 0, Math.PI * 2);
    ctx.fill();
}

두 가지 시야가 합성됩니다: 전방 90도의 넓은 시야(500px)와 캐릭터 주변 360도의 좁은 기본 시야(80px). 기본 시야가 없으면 등 뒤가 완전히 검은색이 되어 방향 감각을 잃기 쉽습니다.

시야 차단: 벽 뒤는 보이지 않는다

단순히 원뿔만 그리면 벽 너머도 보입니다. 이를 해결하기 위해 레이캐스팅으로 벽에 의한 시야 차단을 처리합니다:

function castVisibilityRays(player, walls) {
    const rayCount = 120;  // 120개 광선
    const maxDist = 500;
    const results = [];

    for (let i = 0; i < rayCount; i++) {
        const rayAngle = player.angle - Math.PI/4 +
                         (i / rayCount) * Math.PI/2;

        let hitDist = maxDist;

        // 광선을 단계적으로 진행하며 벽 충돌 검사
        for (let d = 0; d < maxDist; d += 5) {
            const rx = player.x + Math.cos(rayAngle) * d;
            const ry = player.y + Math.sin(rayAngle) * d;

            for (let wall of walls) {
                if (rx > wall.x && rx < wall.x + wall.w &&
                    ry > wall.y && ry < wall.y + wall.h) {
                    hitDist = d;
                    break;
                }
            }
            if (hitDist < maxDist) break;
        }

        results.push({
            angle: rayAngle,
            dist: hitDist
        });
    }
    return results;
}

120개의 광선을 시야각 범위 내에서 균등하게 발사하고, 각 광선이 벽에 부딪히는 거리를 계산합니다. 이 결과로 시야 영역의 실제 경계를 다각형으로 그립니다.

성능 최적화

레이캐스팅은 매 프레임 수행되므로 성능에 민감합니다. 다음 최적화를 적용했습니다:

시각적 효과: 그라데이션 경계

시야 경계가 칼같이 날카로우면 부자연스럽습니다. 그라데이션을 사용하면 시야가 자연스럽게 어두워지는 효과를 줄 수 있습니다. createRadialGradient로 중심에서 가장자리로 갈수록 불투명도가 증가하는 원형 그라데이션을 적용합니다.

또한 시야 범위 끝부분에서 약간의 흐림(feathering) 효과를 주면 시각적 품질이 크게 향상됩니다. Canvas의 shadowBlur 속성을 활용하면 별도의 블러 처리 없이도 부드러운 경계를 만들 수 있습니다.

게임플레이에 미치는 영향

시야 제한 시스템은 게임플레이를 근본적으로 변화시킵니다:

마치며

Canvas 2D의 globalCompositeOperation과 레이캐스팅을 조합하면, 별도의 그래픽 엔진 없이도 설득력 있는 안개 전쟁 시스템을 구현할 수 있습니다. 핵심은 "destination-out으로 마스크 뚫기" + "레이캐스팅으로 벽 차단" 두 가지입니다. 이 시스템 하나만으로도 게임의 긴장감과 전술적 깊이가 크게 향상되었습니다.

← 이전 글: 스캐브 AI 설계 다음 글: 총기 반동 시스템 →