<!DOCTYPE html>

<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<title>Three.js 3D 미로 찾기</title>

<style>
* {
    box-sizing: border-box;
}

body {
    margin: 0;
    overflow: hidden;
    background: #111;
    font-family: Arial, sans-serif;
    color: white;
}

#game {
    width: 100vw;
    height: 100vh;
}

#ui {
    position: fixed;
    top: 15px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 10;

    display: flex;
    gap: 10px;
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;

    padding: 10px 15px;

    background: rgba(0, 0, 0, 0.75);
    border-radius: 10px;
}

button {
    padding: 8px 12px;
    border: 0;
    border-radius: 6px;
    cursor: pointer;
    font-weight: bold;
}

.easy {
    background: #4caf50;
    color: white;
}

.normal {
    background: #2196f3;
    color: white;
}

.hard {
    background: #f44336;
    color: white;
}

.restart {
    background: #ff9800;
    color: white;
}

#minimap {
    position: fixed;
    right: 15px;
    top: 80px;

    width: 180px;
    height: 180px;

    background: #111;

    border: 2px solid white;
    border-radius: 10px;

    z-index: 10;
}

#help {
    position: fixed;
    bottom: 15px;
    left: 50%;

    transform: translateX(-50%);

    padding: 8px 15px;

    background: rgba(0, 0, 0, 0.7);
    border-radius: 8px;

    z-index: 10;
}

#message {
    position: fixed;

    top: 50%;
    left: 50%;

    transform: translate(-50%, -50%);

    display: none;

    z-index: 20;

    padding: 30px 50px;

    text-align: center;

    background: rgba(0, 0, 0, 0.9);

    border: 2px solid #00ff88;
    border-radius: 15px;
}

#message button {
    margin-top: 20px;
    background: #00aa66;
    color: white;
}

@media (max-width: 700px) {

    #minimap {
        width: 130px;
        height: 130px;
        top: 130px;
    }

    #help {
        font-size: 12px;
    }

}
</style>

</head>

<body>

<div id="game"></div>

<div id="ui">

```
<span>🕒 <span id="time">0</span>초</span>

<span>👣 <span id="moves">0</span></span>

<button class="easy" onclick="startGame(8)">
    쉬움
</button>

<button class="normal" onclick="startGame(15)">
    보통
</button>

<button class="hard" onclick="startGame(25)">
    어려움
</button>

<button class="restart" onclick="restartGame()">
    재시작
</button>
```

</div>

<canvas
id="minimap"
width="180"
height="180"

> </canvas>

<div id="message">

```
<div id="result"></div>

<button onclick="restartGame()">
    다시 하기
</button>
```

</div>

<div id="help">

```
↑ W : 전진 |
↓ S : 후진 |
← A : 좌회전 |
→ D : 우회전
```

</div>

<script type="module">

import * as THREE from
"https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js";


let scene;
let camera;
let renderer;

let maze = [];

let mazeSize = 10;

let player;
let goal;

let playerX = 1;
let playerY = 1;

let playerAngle = Math.PI;

let moves = 0;

let gameFinished = false;

let gameStartTime;
let timerInterval;

let lastTime =
    performance.now();


const keys = {};


/* =====================================
   게임 설정
===================================== */

const CELL_SIZE = 4;

const PLAYER_RADIUS = 0.8;

const PLAYER_SPEED = 8;

const TURN_SPEED = 2.5;

const CAMERA_DISTANCE = 10;

const CAMERA_HEIGHT = 6;


/* =====================================
   미니맵
===================================== */

const minimap =
    document.getElementById(
        "minimap"
    );

const ctx =
    minimap.getContext(
        "2d"
    );


/* =====================================
   Three.js 초기화
===================================== */

function init() {

    scene =
        new THREE.Scene();


    scene.background =
        new THREE.Color(
            0x202030
        );


    scene.fog =
        new THREE.Fog(
            0x202030,
            20,
            180
        );


    camera =
        new THREE.PerspectiveCamera(

            70,

            window.innerWidth /
            window.innerHeight,

            0.1,

            1000

        );


    renderer =
        new THREE.WebGLRenderer({

            antialias: true

        });


    renderer.setPixelRatio(

        Math.min(
            window.devicePixelRatio,
            2
        )

    );


    renderer.setSize(

        window.innerWidth,

        window.innerHeight

    );


    renderer.shadowMap.enabled =
        true;


    document
        .getElementById(
            "game"
        )
        .appendChild(
            renderer.domElement
        );


    window.addEventListener(
        "resize",
        onResize
    );


    window.addEventListener(
        "keydown",
        function(event) {

            const key =
                event.key.toLowerCase();


            keys[key] = true;


            if (

                [
                    "arrowup",
                    "arrowdown",
                    "arrowleft",
                    "arrowright"
                ].includes(key)

            ) {

                event.preventDefault();

            }

        }
    );


    window.addEventListener(
        "keyup",
        function(event) {

            keys[
                event.key.toLowerCase()
            ] = false;

        }
    );


    animate();

}


/* =====================================
   화면 크기
===================================== */

function onResize() {

    camera.aspect =
        window.innerWidth /
        window.innerHeight;


    camera.updateProjectionMatrix();


    renderer.setSize(

        window.innerWidth,

        window.innerHeight

    );

}


/* =====================================
   조명
===================================== */

function createLights() {

    const ambient =
        new THREE.AmbientLight(

            0xffffff,

            1.2

        );


    scene.add(
        ambient
    );


    const light =
        new THREE.DirectionalLight(

            0xffffff,

            2

        );


    light.position.set(
        30,
        40,
        20
    );


    scene.add(
        light
    );

}


/* =====================================
   미로 생성
===================================== */

function generateMaze(size) {

    const rows =
        size * 2 + 1;

    const cols =
        size * 2 + 1;


    maze = [];


    for (

        let y = 0;

        y < rows;

        y++

    ) {

        maze[y] = [];


        for (

            let x = 0;

            x < cols;

            x++

        ) {

            maze[y][x] = 1;

        }

    }


    function shuffle(array) {

        for (

            let i =
                array.length - 1;

            i > 0;

            i--

        ) {

            const j =
                Math.floor(

                    Math.random() *
                    (i + 1)

                );


            [
                array[i],
                array[j]
            ] =
            [
                array[j],
                array[i]
            ];

        }

    }


    function carve(x, y) {

        maze[y][x] = 0;


        const directions = [

            [2, 0],
            [-2, 0],
            [0, 2],
            [0, -2]

        ];


        shuffle(
            directions
        );


        for (
            const [dx, dy]
            of directions
        ) {

            const nx =
                x + dx;

            const ny =
                y + dy;


            if (

                nx > 0 &&
                ny > 0 &&

                nx < cols - 1 &&
                ny < rows - 1 &&

                maze[ny][nx] === 1

            ) {

                maze[
                    y + dy / 2
                ][
                    x + dx / 2
                ] = 0;


                carve(
                    nx,
                    ny
                );

            }

        }

    }


    carve(
        1,
        1
    );


    maze[1][1] = 0;

    maze[
        rows - 2
    ][
        cols - 2
    ] = 0;


    playerX = 1;

    playerY = 1;

}


/* =====================================
   씬 제거
===================================== */

function clearScene() {

    while (

        scene.children.length > 0

    ) {

        scene.remove(
            scene.children[0]
        );

    }

}


/* =====================================
   3D 미로 생성
===================================== */

function createMaze() {

    clearScene();

    createLights();


    const rows =
        maze.length;

    const cols =
        maze[0].length;


    const floor =
        new THREE.Mesh(

            new THREE.BoxGeometry(

                cols * CELL_SIZE,

                0.3,

                rows * CELL_SIZE

            ),

            new THREE.MeshStandardMaterial({

                color:
                    0x303030

            })

        );


    floor.position.set(

        (cols - 1) *
        CELL_SIZE / 2,

        -0.3,

        (rows - 1) *
        CELL_SIZE / 2

    );


    scene.add(
        floor
    );


    const wallGeometry =
        new THREE.BoxGeometry(

            CELL_SIZE,

            CELL_SIZE,

            CELL_SIZE

        );


    const wallMaterial =
        new THREE.MeshStandardMaterial({

            color:
                0x4477aa

        });


    for (

        let y = 0;

        y < rows;

        y++

    ) {

        for (

            let x = 0;

            x < cols;

            x++

        ) {

            if (

                maze[y][x] === 1

            ) {

                const wall =
                    new THREE.Mesh(

                        wallGeometry,

                        wallMaterial

                    );


                wall.position.set(

                    x *
                    CELL_SIZE,

                    CELL_SIZE / 2,

                    y *
                    CELL_SIZE

                );


                scene.add(
                    wall
                );

            }

        }

    }


    createPlayer();

    createGoal();

}


/* =====================================
   플레이어 생성
===================================== */

function createPlayer() {

    const geometry =
        new THREE.SphereGeometry(

            PLAYER_RADIUS,

            32,

            32

        );


    const material =
        new THREE.MeshStandardMaterial({

            color:
                0x00ff88

        });


    player =
        new THREE.Mesh(

            geometry,

            material

        );


    player.position.set(

        playerX *
        CELL_SIZE,

        PLAYER_RADIUS,

        playerY *
        CELL_SIZE

    );


    player.rotation.y =
        playerAngle;


    scene.add(
        player
    );

}


/* =====================================
   출구
===================================== */

function createGoal() {

    const rows =
        maze.length;

    const cols =
        maze[0].length;


    goal =
        new THREE.Mesh(

            new THREE.CylinderGeometry(

                1.2,

                1.2,

                0.5,

                32

            ),

            new THREE.MeshStandardMaterial({

                color:
                    0xffd700,

                emissive:
                    0x553300

            })

        );


    goal.position.set(

        (cols - 2) *
        CELL_SIZE,

        0.5,

        (rows - 2) *
        CELL_SIZE

    );


    scene.add(
        goal
    );

}


/* =====================================
   플레이어 방향
===================================== */

function getPlayerDirection() {

    return new THREE.Vector3(

        Math.sin(
            playerAngle
        ),

        0,

        Math.cos(
            playerAngle
        )

    );

}


/* =====================================
   충돌 검사
===================================== */

function isWallCollision(
    worldX,
    worldZ,
    radius = PLAYER_RADIUS
) {

    const minCellX =
        Math.floor(
            (
                worldX -
                radius +
                CELL_SIZE / 2
            ) /
            CELL_SIZE
        );


    const maxCellX =
        Math.floor(
            (
                worldX +
                radius +
                CELL_SIZE / 2
            ) /
            CELL_SIZE
        );


    const minCellY =
        Math.floor(
            (
                worldZ -
                radius +
                CELL_SIZE / 2
            ) /
            CELL_SIZE
        );


    const maxCellY =
        Math.floor(
            (
                worldZ +
                radius +
                CELL_SIZE / 2
            ) /
            CELL_SIZE
        );


    for (

        let y = minCellY;

        y <= maxCellY;

        y++

    ) {

        for (

            let x = minCellX;

            x <= maxCellX;

            x++

        ) {

            if (

                y < 0 ||
                y >= maze.length ||

                x < 0 ||
                x >= maze[0].length

            ) {

                return true;

            }


            if (

                maze[y][x] === 1

            ) {

                const centerX =
                    x *
                    CELL_SIZE;

                const centerZ =
                    y *
                    CELL_SIZE;


                const closestX =
                    THREE.MathUtils.clamp(

                        worldX,

                        centerX -
                        CELL_SIZE / 2,

                        centerX +
                        CELL_SIZE / 2

                    );


                const closestZ =
                    THREE.MathUtils.clamp(

                        worldZ,

                        centerZ -
                        CELL_SIZE / 2,

                        centerZ +
                        CELL_SIZE / 2

                    );


                const dx =
                    worldX -
                    closestX;

                const dz =
                    worldZ -
                    closestZ;


                if (

                    dx * dx +
                    dz * dz <

                    radius * radius

                ) {

                    return true;

                }

            }

        }

    }


    return false;

}


/* =====================================
   플레이어 이동
===================================== */

function updatePlayer(
    deltaTime
) {

    if (

        !player ||
        gameFinished

    ) {

        return;

    }


    /*
       좌회전
    */

    if (

        keys["arrowleft"] ||
        keys["a"]

    ) {

        playerAngle +=

            TURN_SPEED *
            deltaTime;

    }


    /*
       우회전
    */

    if (

        keys["arrowright"] ||
        keys["d"]

    ) {

        playerAngle -=

            TURN_SPEED *
            deltaTime;

    }


    player.rotation.y =
        playerAngle;


    const direction =
        getPlayerDirection();


    let move = 0;


    /*
       전진
    */

    if (

        keys["arrowup"] ||
        keys["w"]

    ) {

        move = 1;

    }


    /*
       후진
    */

    if (

        keys["arrowdown"] ||
        keys["s"]

    ) {

        move = -1;

    }


    if (

        move === 0

    ) {

        updatePlayerGridPosition();

        return;

    }


    const distance =
        PLAYER_SPEED *
        deltaTime *
        move;


    const nextX =
        player.position.x +

        direction.x *
        distance;


    const nextZ =
        player.position.z +

        direction.z *
        distance;


    /*
       X축 이동 및 충돌
    */

    if (

        !isWallCollision(

            nextX,

            player.position.z

        )

    ) {

        player.position.x =
            nextX;

    }


    /*
       Z축 이동 및 충돌
    */

    if (

        !isWallCollision(

            player.position.x,

            nextZ

        )

    ) {

        player.position.z =
            nextZ;

    }


    updatePlayerGridPosition();

    checkGoal();

}


/* =====================================
   플레이어 미로 좌표
===================================== */

function updatePlayerGridPosition() {

    playerX =
        Math.round(

            player.position.x /
            CELL_SIZE

        );


    playerY =
        Math.round(

            player.position.z /
            CELL_SIZE

        );

}


/* =====================================
   카메라 충돌
===================================== */

function getSafeCameraPosition(
    target,
    desired
) {

    const direction =
        desired
            .clone()
            .sub(
                target
            );


    const distance =
        direction.length();


    direction.normalize();


    let safeDistance =
        distance;


    for (

        let d = 1;

        d < distance;

        d += 0.5

    ) {

        const test =
            target
                .clone()
                .add(

                    direction
                        .clone()
                        .multiplyScalar(
                            d
                        )

                );


        if (

            isWallCollision(

                test.x,

                test.z,

                0.2

            )

        ) {

            safeDistance =
                Math.max(

                    1,

                    d - 0.5

                );


            break;

        }

    }


    return target
        .clone()
        .add(

            direction
                .multiplyScalar(
                    safeDistance
                )

        );

}


/* =====================================
   카메라 업데이트
===================================== */

function updateCamera() {

    if (!player) {

        return;

    }


    const direction =
        getPlayerDirection();


    /*
       카메라 기준점
    */

    const target =
        player.position
            .clone();


    target.y += 1.2;


    /*
       카메라는 플레이어 뒤쪽
    */

    const desiredPosition =
        target
            .clone()
            .add(

                direction
                    .clone()
                    .multiplyScalar(
                        -CAMERA_DISTANCE
                    )

            );


    desiredPosition.y +=
        CAMERA_HEIGHT;


    const safePosition =
        getSafeCameraPosition(

            target,

            desiredPosition

        );


    camera.position.lerp(

        safePosition,

        0.15

    );


    /*
       카메라는 플레이어 앞 방향을 바라봄
    */

    const lookTarget =
        player.position
            .clone()
            .add(

                direction
                    .clone()
                    .multiplyScalar(
                        4
                    )

            );


    lookTarget.y +=
        1.2;


    camera.lookAt(
        lookTarget
    );

}


/* =====================================
   목표 확인
===================================== */

function checkGoal() {

    if (

        !goal ||
        gameFinished

    ) {

        return;

    }


    const dx =
        player.position.x -
        goal.position.x;

    const dz =
        player.position.z -
        goal.position.z;


    if (

        Math.sqrt(

            dx * dx +
            dz * dz

        ) < 1.8

    ) {

        finishGame();

    }

}


/* =====================================
   게임 종료
===================================== */

function finishGame() {

    gameFinished =
        true;


    clearInterval(
        timerInterval
    );


    const seconds =
        Math.floor(

            (
                Date.now() -
                gameStartTime
            ) / 1000

        );


    document
        .getElementById(
            "result"
        )
        .innerHTML =

        "🎉 미로 탈출 성공!" +

        "<br><br>" +

        "시간 : " +
        seconds +
        "초" +

        "<br>" +

        "이동 횟수 : " +
        moves;


    document
        .getElementById(
            "message"
        )
        .style.display =
        "block";

}


/* =====================================
   타이머
===================================== */

function startTimer() {

    clearInterval(
        timerInterval
    );


    gameStartTime =
        Date.now();


    timerInterval =
        setInterval(

            function() {

                const seconds =
                    Math.floor(

                        (
                            Date.now() -
                            gameStartTime
                        ) / 1000

                    );


                document
                    .getElementById(
                        "time"
                    )
                    .textContent =
                    seconds;

            },

            1000

        );

}


/* =====================================
   이동 횟수
===================================== */

let lastMoveCellX = 1;
let lastMoveCellY = 1;


function updateMoveCount() {

    if (!player) {

        return;

    }


    const currentX =
        Math.round(

            player.position.x /
            CELL_SIZE

        );


    const currentY =
        Math.round(

            player.position.z /
            CELL_SIZE

        );


    if (

        currentX !==
        lastMoveCellX ||

        currentY !==
        lastMoveCellY

    ) {

        moves++;


        lastMoveCellX =
            currentX;

        lastMoveCellY =
            currentY;


        document
            .getElementById(
                "moves"
            )
            .textContent =
            moves;

    }

}


/* =====================================
   미니맵
===================================== */

function drawMinimap() {

    if (
        maze.length === 0 ||
        !player
    ) {

        return;

    }


    const rows =
        maze.length;

    const cols =
        maze[0].length;


    const cellWidth =
        minimap.width /
        cols;

    const cellHeight =
        minimap.height /
        rows;


    ctx.clearRect(

        0,

        0,

        minimap.width,

        minimap.height

    );


    for (

        let y = 0;

        y < rows;

        y++

    ) {

        for (

            let x = 0;

            x < cols;

            x++

        ) {

            ctx.fillStyle =
                maze[y][x] === 1
                    ? "#666"
                    : "#111";


            ctx.fillRect(

                x *
                cellWidth,

                y *
                cellHeight,

                cellWidth,

                cellHeight

            );

        }

    }


    /*
       출구
    */

    ctx.fillStyle =
        "#ffd700";


    ctx.fillRect(

        (cols - 2) *
        cellWidth,

        (rows - 2) *
        cellHeight,

        cellWidth,

        cellHeight

    );


    /*
       플레이어 위치
    */

    const px =
        (
            player.position.x /
            CELL_SIZE
        ) *
        cellWidth;


    const py =
        (
            player.position.z /
            CELL_SIZE
        ) *
        cellHeight;


    ctx.fillStyle =
        "#00ff88";


    ctx.beginPath();


    ctx.arc(

        px,

        py,

        Math.max(

            3,

            Math.min(
                cellWidth,
                cellHeight
            ) * 0.4

        ),

        0,

        Math.PI * 2

    );


    ctx.fill();


    /*
       플레이어 방향
    */

    const direction =
        getPlayerDirection();


    ctx.strokeStyle =
        "white";

    ctx.lineWidth =
        2;


    ctx.beginPath();

    ctx.moveTo(
        px,
        py
    );


    ctx.lineTo(

        px +
        direction.x *
        12,

        py +
        direction.z *
        12

    );


    ctx.stroke();

}


/* =====================================
   게임 시작
===================================== */

window.startGame =
function(size) {

    mazeSize =
        size;


    moves = 0;

    gameFinished =
        false;


    /*
       초기 방향

       미로의 -Z 방향을 바라봄
    */

    playerAngle =
        Math.PI;


    document
        .getElementById(
            "moves"
        )
        .textContent =
        "0";


    document
        .getElementById(
            "time"
        )
        .textContent =
        "0";


    document
        .getElementById(
            "message"
        )
        .style.display =
        "none";


    generateMaze(
        size
    );


    createMaze();


    lastMoveCellX =
        playerX;

    lastMoveCellY =
        playerY;


    /*
       초기 카메라 위치
    */

    const direction =
        getPlayerDirection();


    const target =
        player.position
            .clone();


    target.y +=
        1.2;


    camera.position
        .copy(
            target
        )
        .add(

            direction
                .clone()
                .multiplyScalar(
                    -CAMERA_DISTANCE
                )

        );


    camera.position.y +=
        CAMERA_HEIGHT;


    const lookTarget =
        player.position
            .clone()
            .add(

                direction
                    .clone()
                    .multiplyScalar(
                        4
                    )

            );


    lookTarget.y +=
        1.2;


    camera.lookAt(
        lookTarget
    );


    startTimer();

};


/* =====================================
   재시작
===================================== */

window.restartGame =
function() {

    startGame(
        mazeSize
    );

};


/* =====================================
   게임 루프
===================================== */

function animate() {

    requestAnimationFrame(
        animate
    );


    const currentTime =
        performance.now();


    const deltaTime =
        Math.min(

            (
                currentTime -
                lastTime
            ) / 1000,

            0.05

        );


    lastTime =
        currentTime;


    updatePlayer(
        deltaTime
    );


    if (

        player &&
        !gameFinished

    ) {

        updateMoveCount();

    }


    if (goal) {

        goal.rotation.y +=
            0.03;


        goal.position.y =

            0.7 +

            Math.sin(

                Date.now() *
                0.003

            ) *
            0.3;

    }


    updateCamera();

    drawMinimap();


    renderer.render(
        scene,
        camera
    );

}


/* =====================================
   시작
===================================== */

init();

startGame(10);

</script>

</body>
</html>

 

 

'Software > JavaScript' 카테고리의 다른 글

Node.js 실무 백엔드 개발 #5  (0) 2026.07.12
Node.js 실무 백엔드 개발 #4  (0) 2026.07.05
Node.js 실무 백엔드 개발 #3  (0) 2026.06.28
Node.js 실무 백엔드 개발 #2  (0) 2026.06.21
Node.js 실무 백엔드 개발 #1  (0) 2026.06.14

+ Recent posts