<!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>