66. Maze game
텍스트 정답 기반 미로게임
작성일
4/29/2025작성
마로니에수정일
4/28/2025Maze Game
- 텍스트 기반으로 경로 안내 미로 게임
- 상하좌우 이동 및 Backspace(되돌아가기)
코드
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>미로 게임 (학습용 주석 포함)</title>
<style>
/* 스타일 */
body {
font-family: sans-serif;
text-align: center;
background: #f9f9f9;
}
/* 캔버스 스타일 (미로가 그려질 영역) */
canvas {
border: 2px solid #333;
background: #fff;
margin-top: 10px;
touch-action: none;
/* 모바일 터치 스크롤 방지 */
}
/* 입력 요소, 버튼, 선택 메뉴 스타일 */
button {
padding: 10px 20px;
margin: 10px;
font-size: 1rem;
border-radius: 10px;
border: none;
color: white;
cursor: pointer;
}
button:hover {
background-color: #ff0000;
}
select {
-webkit-appearance: none;
-moz-appearance: none;
font-size: 1rem;
margin: 0 0.5rem;
appearance: none;
padding: 10px;
width: 150px;
cursor: pointer;
}
/* 타이머 텍스트 스타일 */
#timer {
font-weight: bold;
color: darkred;
}
/* 흔들림 애니메이션 (오답 시) */
.shake {
animation: shake 0.3s;
}
/* --- 로딩 인디케이터 스타일 추가 --- */
#loadingOverlay {
position: absolute;
/* 캔버스 위에 위치 */
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.7);
/* 반투명 흰색 배경 */
z-index: 10;
/* 다른 요소들 위에 표시 */
display: flex;
justify-content: center;
align-items: center;
}
.loader {
border: 8px solid #f3f3f3;
/* Light grey */
border-top: 8px solid #3498db;
/* Blue */
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.hidden {
display: none !important;
/* 확실하게 숨기기 */
}
@keyframes shake {
0% {
transform: translate(2px, 2px);
}
25% {
transform: translate(-2px, -2px);
}
50% {
transform: translate(2px, -2px);
}
75% {
transform: translate(-2px, 2px);
}
100% {
transform: translate(0, 0);
}
}
</style>
</head>
<body>
<div style="overflow: hidden;">
<h1>🎮 정답 기반 미로 게임</h1>
<div style="display: flex; gap: 1rem; align-items: center; justify-content:center; width: 100vw;">
<!-- 게임 난이도 선택 드롭다운 -->
<div style="width: 300px; display:flex; align-items: center; justify-content: center;">
<label style="font-size: 1.3rem; color: #454545;">난이도 </label>
<select id="levelSelect"></select>
</div>
<!-- 게임 컨트롤 버튼 -->
<button onclick="startGame()" id="startGameBtn">게임 시작</button>
<button onclick="stopTimer()" id="startGameBtn">게임 중지</button>
<button onclick="showAnswerPath()" id="showPathBtn">정답 확인</button>
<button onclick="hideAnswerPath()" id="hidePathBtn">정답 숨기기</button>
</div>
<!-- 경과 시간 표시 영역 -->
<p id="timer">⏱ 경과 시간: 0초</p>
<!-- HTML Body 중간, 예를 들어 캔버스 위에 배치 -->
<div id="loadingOverlay" class="hidden">
<div class="loader"></div>
</div>
<!-- 미로가 그려질 캔버스 요소 -->
<canvas id="maze" width="1000"
height="760"
style="border: solid 5px gray;
position: relative">
</canvas>
<!-- 정답 입력 및 제출 -->
<div style="display: flex; flex-direction: column; gap: 10px; padding: 1rem;">
<input id="answerInput" placeholder="정답을 입력하세요"
style="width: auto; padding: 20px; border: solid 1px gray;border-radius:5px;" />
<button onclick="checkAnswer()">정답 제출</button>
</div>
<!-- 결과 메시지 표시 영역 -->
<p id="resultMsg"></p>
</div>
<script>
// --- 전역 변수 선언 ---
// 각 레벨별 정답 문장 배열
const answersList = [
"한용운은 오늘도 민족의 독립을 위해서 노력하는 중이다",
"연희 전문졸업반에 재학 중일 때 지은 것으로 알려져 있는 십자가는 순절 정신과 속죄양 의식을 바탕으로 한 자기희생의 이념을 표출하고 있다",
"4연에서는 그러한 갈등을 겪은 화자가 첨탑에 오를 수 없다면 차라리 그리스도의 희생을 본받아 고난 속에서 신음하고 있는 민족을 위해 속죄양이 되고 싶다",
"5연은 자기희생이나 속죄양 의식이 응축되어 나타난 부분으로 주제연에 해당한다",
"1연에서는 상실의 상황과 그 상황에서 무의식적으로 나온 행동을 형상화하고 있는데 화자는 무언가를 잃어버렸지만 그것이 무엇인지 또한 모른다",
"3연에서는 돌담 안쪽으로 들어갈 수 있는 통로를 제시하고 있지만 그것이 긴 그림자를 드리운 채 쇠문으로 굳게 닫혀 있다고 함으로써 암시한다",
"4연에서는 시간 속에서 시간과 함께 살아가는 삶의 과정으로서의 길의 의미를 형상화하고 있는데 길의 진행은 곧 시간의 경과를 의미한다",
"5연에서는 부끄러움을 통한 자아의 갈등과 각성을 형상화하고 있는데 이상적 자아를 회복할 수 없음을 깨달은 화자가 쳐다본 하늘은 현실적이다",
"7연에서는 삶에 대한 화자의 태도를 포괄적으로 제시하고 불모의 길을 걷는 것은 담 저쪽에 존재해 있는 잃어버린 자아를 찾기 위함이다"
];
let answer = ""; // 현재 레벨의 정답 문장
let COLS, ROWS, CELL; // 미로의 열(칸 수), 행(칸 수), 각 셀의 크기
const canvas = document.getElementById("maze"); // HTML의 canvas 요소를 가져옴
const ctx = canvas.getContext("2d"); // 캔버스에 그림을 그리기 위한 2D 렌더링 컨텍스트
let player = { x: 0, y: 0 }; // 플레이어의 현재 위치 (x, y 좌표)
let grid = []; // 미로의 모든 셀(칸) 정보를 담는 배열 (Cell 객체 저장)
let path = []; // 미로의 정답 경로 (좌표 객체 {i, j} 저장)
let letters = []; // 미로에 배치될 글자 정보 배열 ({x, y, char, isAnswer, isLabel})
let moveStack = []; // 플레이어의 이동 경로를 기록하는 스택 (되돌아가기 기능에 사용)
let showPath = false; // 정답 경로 표시 여부 플래그
let timerStart = 0; // 게임 시작 시간 (타이머 계산용)
let interval; // 타이머 반복 실행 ID (clearInterval에 사용)
let visitedCells = new Set(); // 플레이어가 방문한 셀의 좌표 문자열("x,y")을 저장하는 Set (글자 색 변경 및 중복 방지)
// --- 초기 설정 함수 ---
/**
* 난이도 선택 드롭다운 메뉴를 생성하고 초기화하는 함수.
* 1부터 9까지의 레벨 옵션을 생성하여 <select> 요소에 추가.
*/
function setupLevels() {
const select = document.getElementById("levelSelect");
for (let i = 1; i <= 9; i++) {
const opt = document.createElement("option"); // 새로운 <option> 요소 생성
opt.value = i; // 옵션 값 설정 (레벨 번호)
opt.innerText = `${i}단계`; // 옵션 텍스트 설정
select.appendChild(opt); // <select>에 옵션 추가
}
select.value = 1; // 기본 선택값을 1단계로 설정
}
// HTML 문서 로딩이 완료되면 setupLevels 함수를 실행 (이벤트 리스너)
document.addEventListener("DOMContentLoaded", setupLevels);
// --- 유틸리티 함수 ---
/**
* 2차원 좌표 (i, j)를 1차원 배열 grid의 인덱스로 변환하는 함수.
* @param {number} i - 열(가로) 인덱스
* @param {number} j - 행(세로) 인덱스
* @returns {number} 1차원 배열 인덱스. 유효하지 않은 좌표면 -1 반환.
*/
function index(i, j) {
if (i < 0 || j < 0 || i >= COLS || j >= ROWS) return -1; // 미로 범위를 벗어나면 -1 반환
return i + j * COLS; // 1차원 인덱스 계산 (가로 위치 + 세로 위치 * 가로 칸 수)
}
// --- 미로 생성 관련 함수 ---
/**
* 미로의 각 셀(칸)을 나타내는 객체 생성자 함수.
* @param {number} i - 셀의 열(가로) 인덱스
* @param {number} j - 셀의 행(세로) 인덱스
*/
function Cell(i, j) {
this.i = i; // 열 인덱스
this.j = j; // 행 인덱스
// 각 셀의 벽 상태 [상, 우, 하, 좌] (true: 벽 있음, false: 벽 없음)
this.walls = [true, true, true, true];
this.visited = false; // 미로 생성 시 방문 여부 플래그
}
/**
* 두 인접한 셀 사이의 벽을 제거하는 함수.
* @param {Cell} a - 첫 번째 셀 객체
* @param {Cell} b - 두 번째 셀 객체 (a와 인접해야 함)
*/
function removeWalls(a, b) {
let dx = a.i - b.i; // x축 방향 차이
let dy = a.j - b.j; // y축 방향 차이
// x축으로 1 차이 (a가 b의 오른쪽) -> a의 왼쪽 벽, b의 오른쪽 벽 제거
if (dx == 1) { a.walls[3] = false; b.walls[1] = false; }
// x축으로 -1 차이 (a가 b의 왼쪽) -> a의 오른쪽 벽, b의 왼쪽 벽 제거
if (dx == -1) { a.walls[1] = false; b.walls[3] = false; }
// y축으로 1 차이 (a가 b의 아래쪽) -> a의 위쪽 벽, b의 아래쪽 벽 제거
if (dy == 1) { a.walls[0] = false; b.walls[2] = false; }
// y축으로 -1 차이 (a가 b의 위쪽) -> a의 아래쪽 벽, b의 위쪽 벽 제거
if (dy == -1) { a.walls[2] = false; b.walls[0] = false; }
}
// 헷갈리게 벽을 허는 비율.. 높을 수록 벽을 많아 허뭄
function openExtraPaths(probability = 0.2) {
for (let c of grid) {
// 각 셀마다 랜덤 확률로
if (Math.random() < probability) {
const neighbors = [];
// 각 방향별로 이웃 셀을 조사
let top = grid[index(c.i, c.j - 1)];
let right = grid[index(c.i + 1, c.j)];
let bottom = grid[index(c.i, c.j + 1)];
let left = grid[index(c.i - 1, c.j)];
if (top && c.walls[0]) neighbors.push({ dir: 0, cell: top });
if (right && c.walls[1]) neighbors.push({ dir: 1, cell: right });
if (bottom && c.walls[2]) neighbors.push({ dir: 2, cell: bottom });
if (left && c.walls[3]) neighbors.push({ dir: 3, cell: left });
if (neighbors.length > 0) {
const { dir, cell: neighbor } = neighbors[Math.floor(Math.random() * neighbors.length)];
// 벽 제거
c.walls[dir] = false;
neighbor.walls[(dir + 2) % 4] = false; // 반대편 벽도 제거
}
}
}
}
/**
* 미로를 생성하는 함수 (Depth First Search - Recursive Backtracker 알고리즘 사용).
* 미로 생성이 완료되면 콜백 함수를 호출합니다.
* @param {function} callback - 미로 생성이 완료된 후 실행될 함수
*/
function generateMazeBacup(callback) {
// 1. 그리드 초기화: 모든 셀 객체를 생성하여 grid 배열에 저장
grid = [];
for (let j = 0; j < ROWS; j++) {
for (let i = 0; i < COLS; i++) {
grid.push(new Cell(i, j));
}
}
// 2. 시작 셀 설정 및 스택 초기화
let current = grid[0]; // (0, 0) 셀에서 시작
current.visited = true; // 시작 셀 방문 처리
let stack = []; // 되돌아갈 경로를 저장할 스택
const batchSize = 100;
// 3. DFS 스텝 함수 (재귀, 반복호출)
function step() {
// 현재 셀에서 방문하지 않은 이웃 셀 찾기
let neighbors = [];
let top = grid[index(current.i, current.j - 1)];
let right = grid[index(current.i + 1, current.j)];
let bottom = grid[index(current.i, current.j + 1)];
let left = grid[index(current.i - 1, current.j)];
// 유효하고 방문하지 않은 이웃만 neighbors 배열에 추가
[top, right, bottom, left].forEach(n => { if (n && !n.visited) neighbors.push(n); });
if (neighbors.length > 0) { // 방문할 이웃이 있으면
stack.push(current); // 현재 셀을 스택에 push (나중에 되돌아올 수 있도록)
// 이웃 중 하나를 무작위로 선택
let next = neighbors[Math.floor(Math.random() * neighbors.length)];
removeWalls(current, next); // 현재 셀과 다음 셀 사이의 벽 제거
next.visited = true; // 다음 셀 방문 처리
current = next; // 현재 셀을 다음 셀로 이동
// 비동기 처리: 브라우저 렌더링 시간을 확보하고 스택 오버플로우 방지
setTimeout(step, 0); // 다음 스텝을 잠시 후에 실행
} else if (stack.length > 0) { // 방문할 이웃은 없고, 스택에 되돌아갈 경로가 있으면
current = stack.pop(); // 스택에서 이전 셀을 꺼내와 현재 셀로 설정 (막다른 길에서 되돌아감)
setTimeout(step, 0); // 다음 스텝 실행
} else { // 더 이상 갈 곳도 없고 스택도 비었으면 미로 생성 완료
openExtraPaths(); // 헷갈리게 하는 부분
callback(); // 완료 후 콜백 함수 실행 (예: findAnswerPath, draw 등 호출)
}
}
step(); // 미로 생성 시작
}
function generateMaze(callback) {
// 1. 그리드 초기화: 모든 셀 객체를 생성하여 grid 배열에 저장
grid = [];
for (let j = 0; j < ROWS; j++) {
for (let i = 0; i < COLS; i++) {
grid.push(new Cell(i, j));
}
}
// 2. 시작 셀 설정 및 스택 초기화
let current = grid[0]; // (0, 0) 셀에서 시작
current.visited = true; // 시작 셀 방문 처리
let stack = []; // 되돌아갈 경로를 저장할 스택
const batchSize = 100;
// 3. DFS 스텝 함수 (재귀, 반복호출)
function stepBatch() {
let stepsInBatch = 0;
while (stepsInBatch < batchSize) {
// 현재 셀에서 방문하지 않은 이웃 셀 찾기
let neighbors = [];
let top = grid[index(current.i, current.j - 1)];
let right = grid[index(current.i + 1, current.j)];
let bottom = grid[index(current.i, current.j + 1)];
let left = grid[index(current.i - 1, current.j)];
// 유효하고 방문하지 않은 이웃만 neighbors 배열에 추가
[top, right, bottom, left].forEach(n => { if (n && !n.visited) neighbors.push(n); });
if (neighbors.length > 0) { // 방문할 이웃이 있으면
stack.push(current); // 현재 셀을 스택에 push (나중에 되돌아올 수 있도록)
// 이웃 중 하나를 무작위로 선택
let next = neighbors[Math.floor(Math.random() * neighbors.length)];
removeWalls(current, next); // 현재 셀과 다음 셀 사이의 벽 제거
next.visited = true; // 다음 셀 방문 처리
current = next; // 현재 셀을 다음 셀로 이동
// 비동기 처리: 브라우저 렌더링 시간을 확보하고 스택 오버플로우 방지
} else if (stack.length > 0) { // 방문할 이웃은 없고, 스택에 되돌아갈 경로가 있으면
current = stack.pop(); // 스택에서 이전 셀을 꺼내와 현재 셀로 설정 (막다른 길에서 되돌아감)
} else { // 더 이상 갈 곳도 없고 스택도 비었으면 미로 생성 완료
// --- 생성 완료 ---
openExtraPaths(); // 헷갈리게 하는 부분
callback(); // 완료 후 콜백 함수 실행 (예: findAnswerPath, draw 등 호출)
return;
}
//
stepsInBatch++
// 스택이 비고 이웃도 없으면 루프 강제 종료 (생성 완료)
if (neighbors.length === 0 && stack.length === 0) {
openExtraPaths();
callback();
return;
}
} // while 루프 끝 (배치 처리 완료 )
// 다음 배치 처리 예약
setTimeout(stepBatch, 0);
}
stepBatch(); // 미로 생성 시작 배치 처리 시작
}
// --- 게임 진행 함수 ---
/**
* 게임을 시작하는 함수.
* 레벨을 설정하고, 미로 크기를 계산하며, 관련 변수들을 초기화합니다.
* 미로 생성, 정답 경로 찾기, 글자 배치, 그리기, 타이머 시작 등을 순차적으로 호출합니다.
*/
function startGame() {
// --- 로딩 시작 ---
const loadingOverlay = document.getElementById('loadingOverlay');
const startGameBtn = document.getElementById('startGameBtn'); // ID 가정
const showPathBtn = document.getElementById('showPathBtn'); // ID 가정
const hidePathBtn = document.getElementById('hidePathBtn'); // ID 가정
const levelSelect = document.getElementById('levelSelect');
if (loadingOverlay) loadingOverlay.classList.remove('hidden');
if (startGameBtn) startGameBtn.disabled = true;
if (showPathBtn) showPathBtn.disabled = true;
if (hidePathBtn) hidePathBtn.disabled = true;
if (levelSelect) levelSelect.disabled = true;
// ---------------
stopTimer();
// 1. 레벨 및 정답 설정
const level = parseInt(document.getElementById("levelSelect").value);
answer = answersList[level - 1]; // 선택된 레벨에 맞는 정답 문장 설정
// 2. 미로 크기 및 셀 크기 계산 (캔버스 크기에 맞춰 동적 조절)
COLS = Math.floor(canvas.width / 40); // 가로 칸 수
ROWS = Math.floor(canvas.height / 40); // 세로 칸 수
CELL = Math.min(canvas.width / COLS, canvas.height / ROWS); // 각 셀의 크기 (정사각형 유지)
// 3. 게임 상태 초기화
player = { x: 0, y: 0 }; // 플레이어 위치 초기화 (0, 0)
moveStack = [{ x: 0, y: 0 }]; // 이동 경로 스택 초기화 (시작 위치 포함)
visitedCells = new Set(["0,0"]); // 방문 셀 기록 초기화 (시작 위치 포함, "x,y" 문자열 형태)
showPath = false; // 정답 경로 표시 플래그 초기화
document.getElementById("resultMsg").innerText = ""; // 결과 메시지 초기화
document.getElementById("answerInput").value = ""; // 정답 입력창 초기화
generateMaze(() => {
findAnswerPath(); // 정답 경로 계산
placeLetters(); // 정답 경로 위에 글자 배치
placeDecoyLetters(); // ★★★ 미끼 글자 배치 함수 호출 추가 ★★★
draw(); // 초기 미로 상태 그리기
startTimer(); // 게임 타이머 시작
setupTouchControls(); // 터치 이벤트 리스너 설정
});
// --- 로딩 완료 ---
if (loadingOverlay) loadingOverlay.classList.add('hidden');
if (startGameBtn) startGameBtn.disabled = false;
if (showPathBtn) showPathBtn.disabled = false;
if (hidePathBtn) hidePathBtn.disabled = false;
if (levelSelect) levelSelect.disabled = false;
// ---------------
}
/**
* 미로의 시작점(0,0)부터 도착점(COLS-1, ROWS-1)까지의 정답 경로를 찾는 함수 (DFS 사용).
* 찾은 경로는 path 배열에 {i, j} 객체 형태로 저장됩니다.
*/
function findAnswerPathBackup() {
path = []; // 경로 배열 초기화
let visited = new Set(); // 경로 탐색 중 방문 여부 기록 (findAnswerPath 함수 내에서만 사용)
// DFS 탐색 함수 (재귀)
function dfs(x, y) {
// 현재 위치를 문자열 "x,y" 형태로 visited Set에 추가
visited.add(x + "," + y);
// 현재 위치를 경로 배열 path에 추가
path.push({ i: x, j: y });
// 도착점에 도달했으면 true 반환 (탐색 성공)
if (x === COLS - 1 && y === ROWS - 1) {
return true;
}
// 이동 가능한 방향 배열 [우, 하, 좌, 상] (우선순위 변경 가능)
let directions = [[1, 0], [0, 1], [-1, 0], [0, -1]];
// 각 방향으로 탐색 시도
for (let [dx, dy] of directions) {
let nx = x + dx; // 다음 x 좌표
let ny = y + dy; // 다음 y 좌표
// 다음 위치가 미로 범위 내에 있고, 아직 방문하지 않았는지 확인
if (nx >= 0 && ny >= 0 && nx < COLS && ny < ROWS && !visited.has(nx + "," + ny)) {
let currentCell = grid[index(x, y)]; // 현재 셀 정보 가져오기
// 현재 셀과 다음 셀 사이에 벽이 없는지 확인 (이동 가능한지 체크)
if (
(dx == 1 && !currentCell.walls[1]) || // 오른쪽 이동 시 오른쪽 벽 없음
(dx == -1 && !currentCell.walls[3]) || // 왼쪽 이동 시 왼쪽 벽 없음
(dy == 1 && !currentCell.walls[2]) || // 아래쪽 이동 시 아래쪽 벽 없음
(dy == -1 && !currentCell.walls[0]) // 위쪽 이동 시 위쪽 벽 없음
) {
// 이동 가능하면 다음 위치에서 재귀적으로 dfs 호출
if (dfs(nx, ny)) {
return true; // 도착점을 찾았으면 true를 계속 반환하여 재귀 종료
}
}
}
}
// 모든 방향 탐색 후에도 도착점을 못 찾으면 막다른 길이므로 경로에서 현재 위치 제거
path.pop();
return false; // 탐색 실패(막다른 길)를 알림
}
dfs(0, 0); // 시작점(0,0)에서 탐색 시작
}
function findAnswerPath() {
path = [];
let visited = new Set();
function dfs(x, y) {
if (x === COLS - 1 && y === ROWS - 1) {
path.push({ i: x, j: y });
return true;
}
visited.add(x + "," + y);
path.push({ i: x, j: y });
let directions = [[1, 0], [0, 1], [-1, 0], [0, -1]];
// 방향 랜덤 섞기
for (let i = directions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[directions[i], directions[j]] = [directions[j], directions[i]];
}
for (let [dx, dy] of directions) {
let nx = x + dx;
let ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < COLS && ny < ROWS && !visited.has(nx + "," + ny)) {
let currentCell = grid[index(x, y)];
if (
(dx == 1 && !currentCell.walls[1]) ||
(dx == -1 && !currentCell.walls[3]) ||
(dy == 1 && !currentCell.walls[2]) ||
(dy == -1 && !currentCell.walls[0])
) {
if (dfs(nx, ny)) return true;
}
}
}
path.pop();
return false;
}
dfs(0, 0);
}
/**
* 정답 경로 위에 글자를 자연스럽고 랜덤한 간격 + **올바른 순서**로 배치하는 함수.
*/
function placeLetters() {
letters = []; // 글자 배열 초기화
const answerChars = answer.split(""); // 정답 문자 배열
const N = answerChars.length; // 정답 문자 개수
// 경로에서 시작점(0)과 도착점(path.length-1)은 제외
const availablePathCoords = path.slice(1, -1);
const L = availablePathCoords.length; // 사용 가능한 경로 길이
if (L < N) {
console.error(`경로 길이 부족! 사용 가능 경로: ${L}, 필요한 글자 수: ${N}`);
// 비상 로직: 최소한의 배치라도 시도
let step = Math.max(1, Math.floor(L / N));
for (let i = 0; i < N; i++) {
let idx = Math.min(i * step, L - 1);
if (availablePathCoords[idx]) {
letters.push({ x: availablePathCoords[idx].i, y: availablePathCoords[idx].j, char: answerChars[i], isAnswer: true });
}
}
letters.push({ x: 0, y: 0, char: "출발", isAnswer: false, isLabel: true });
letters.push({ x: COLS - 1, y: ROWS - 1, char: "도착", isAnswer: false, isLabel: true });
return;
}
const occupiedIndices = new Set(); // 사용된 availablePathCoords 인덱스 저장
let lastPlacedIndex = -1; // ★★★ 마지막으로 배치된 문자의 availablePathCoords 인덱스 추적
// 각 글자에 대해 순서대로 배치 위치 계산 및 시도
for (let i = 0; i < N; i++) {
let targetIndex;
let offsetRange;
// ★★★ 다음 글자가 시작해야 할 최소 인덱스 ★★★
const searchStartIndex = lastPlacedIndex + 1;
// 1. 이상적인 목표 인덱스 계산 (비율 기반, 이전과 동일)
if (i === N - 1) { // 마지막 글자 처리
const endIndex = L - 1;
const startIndex = Math.max(searchStartIndex, L - Math.ceil(L * 0.15)); // 시작 가능 인덱스 고려
targetIndex = startIndex + Math.floor(Math.random() * Math.max(0, (endIndex - startIndex + 1)));
offsetRange = Math.floor(L / N * 0.3);
} else { // 일반 글자 처리
// 목표 인덱스는 전체 경로 비율 기준, 단 searchStartIndex보다는 커야 함
targetIndex = Math.max(searchStartIndex, Math.round(i * (L - 1) / (N - 1)));
offsetRange = Math.floor(L / N * 0.4);
}
// 2. 랜덤 오프셋 적용
const randomOffset = Math.floor(Math.random() * (offsetRange * 2 + 1)) - offsetRange;
let placementIndex = targetIndex + randomOffset;
// 3. 인덱스 경계값 조정 (★ searchStartIndex 이상, L-1 이하 ★)
placementIndex = Math.max(searchStartIndex, Math.min(L - 1, placementIndex));
// 4. 충돌 처리: placementIndex부터 시작하여 *다음* 빈 곳 찾기
let foundIndex = -1;
for (let currentIdx = placementIndex; currentIdx < L; currentIdx++) {
if (!occupiedIndices.has(currentIdx)) {
foundIndex = currentIdx;
break; // 찾으면 바로 종료
}
}
// 만약 앞에서 못 찾았다면, placementIndex부터 *이전* (searchStartIndex까지) 빈 곳 찾기
if (foundIndex === -1) {
for (let currentIdx = placementIndex - 1; currentIdx >= searchStartIndex; currentIdx--) {
if (!occupiedIndices.has(currentIdx)) {
foundIndex = currentIdx;
break;
}
}
}
// 5. 배치 또는 오류 처리
if (foundIndex !== -1) {
const p = availablePathCoords[foundIndex];
letters.push({ x: p.i, y: p.j, char: answerChars[i], isAnswer: true });
occupiedIndices.add(foundIndex); // 사용된 인덱스 기록
lastPlacedIndex = foundIndex; // ★★★ 마지막 배치 인덱스 업데이트 ★★★
} else {
console.warn(`글자 '${answerChars[i]}' 배치 실패! [${searchStartIndex}, ${L - 1}] 범위 내 빈 공간 없음.`);
// 비상 로직: searchStartIndex 이후 첫 번째 빈 칸에 강제 배치
let emergencyIndex = -1;
for (let tempIdx = searchStartIndex; tempIdx < L; tempIdx++) {
if (!occupiedIndices.has(tempIdx)) {
emergencyIndex = tempIdx;
break;
}
}
if (emergencyIndex !== -1) {
const p = availablePathCoords[emergencyIndex];
letters.push({ x: p.i, y: p.j, char: answerChars[i], isAnswer: true });
occupiedIndices.add(emergencyIndex);
lastPlacedIndex = emergencyIndex; // ★★★ 비상 배치 인덱스 업데이트 ★★★
} else {
console.error(`!!! 비상 배치조차 실패 (${i + 1}번째 글자) !!!`);
// 더 이상 진행 불가 또는 다른 처리
}
}
}
// 시작점과 도착점 레이블 추가
letters.push({ x: 0, y: 0, char: "출발", isAnswer: false, isLabel: true });
letters.push({ x: COLS - 1, y: ROWS - 1, char: "도착", isAnswer: false, isLabel: true });
console.log(`글자 배치 완료 (순서 보장 시도)`);
}
/**
* 캔버스에 현재 게임 상태 (미로, 글자, 플레이어, 정답 경로 등)를 그리는 함수.
* 게임 상태가 변경될 때마다 호출되어 화면을 업데이트.
*/
function draw() {
// 1. 캔버스 초기화 (이전 그림 지우기)
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 2. 미로 벽 그리기
for (let c of grid) {
let x = c.i * CELL; // 셀의 화면 x 좌표
let y = c.j * CELL; // 셀의 화면 y 좌표
ctx.strokeStyle = "black"; // 벽 색상
ctx.lineWidth = 2; // 벽 두께
// 각 셀의 walls 배열 상태에 따라 벽 그리기
if (c.walls[0]) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + CELL, y); ctx.stroke(); } // 상단 벽
if (c.walls[1]) { ctx.beginPath(); ctx.moveTo(x + CELL, y); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); } // 우측 벽
if (c.walls[2]) { ctx.beginPath(); ctx.moveTo(x + CELL, y + CELL); ctx.lineTo(x, y + CELL); ctx.stroke(); } // 하단 벽
if (c.walls[3]) { ctx.beginPath(); ctx.moveTo(x, y + CELL); ctx.lineTo(x, y); ctx.stroke(); } // 좌측 벽
}
// 3. 정답 경로 그리기 (showPath 플래그가 true일 때만)
if (showPath) {
ctx.strokeStyle = "#00ffff"; // 경로 색상
ctx.lineWidth = 4; // 경로 두께
ctx.beginPath();
// 경로의 시작점에서 선 그리기 시작
ctx.moveTo(path[0].i * CELL + CELL / 2, path[0].j * CELL + CELL / 2);
// 경로의 각 점을 순서대로 연결
for (let p of path) {
ctx.lineTo(p.i * CELL + CELL / 2, p.j * CELL + CELL / 2);
}
ctx.stroke(); // 경로 그리기 완료
}
// 4. 글자 그리기
for (let l of letters) {
// 글자 색상 설정: 정답 글자이고 해당 셀을 방문했으면('visitedCells'에 있으면) 빨간색, 아니면 검은색
ctx.fillStyle = l.isAnswer && visitedCells.has(`${l.x},${l.y}`) ? "red" : "black";
// 폰트 설정: 레이블(출발/도착)과 일반 글자 크기 다르게 설정
ctx.font = l.isLabel ? `${CELL / 4}px sans-serif` : `${CELL / 3}px sans-serif`;
ctx.textAlign = "center"; // 가로 중앙 정렬
ctx.textBaseline = "middle"; // 세로 중앙 정렬
// 셀 중앙에 글자 그리기
ctx.fillText(l.char, l.x * CELL + CELL / 2, l.y * CELL + CELL / 2);
}
// 5. 플레이어 그리기
ctx.fillStyle = "blue"; // 플레이어 색상
// 플레이어 위치에 사각형 그리기 (셀 중앙에 위치하도록)
ctx.fillRect(player.x * CELL + CELL / 4, player.y * CELL + CELL / 4, CELL / 2, CELL / 2);
}
/**
* 플레이어를 지정된 방향(dx, dy)으로 이동시키는 함수.
* 벽 충돌 체크, 이동 처리, 이동 기록(moveStack, visitedCells) 업데이트, 화면 다시 그리기를 수행합니다.
* @param {number} dx - x축 이동량 (-1: 좌, 1: 우, 0: 이동 없음)
* @param {number} dy - y축 이동량 (-1: 상, 1: 하, 0: 이동 없음)
*/
function move(dx, dy) {
// 1. 다음 예상 위치 계산
let nextX = player.x + dx;
let nextY = player.y + dy;
// 2. 미로 범위 벗어나는지 확인
if (nextX < 0 || nextY < 0 || nextX >= COLS || nextY >= ROWS) return; // 벗어나면 이동 불가
// 3. 현재 셀과 다음 셀 정보 가져오기
let currentCell = grid[index(player.x, player.y)];
let nextCell = grid[index(nextX, nextY)]; // 다음 셀 정보 (없을 수도 있지만, 범위 체크로 사실상 항상 있음)
if (!nextCell) return; // 혹시 모를 오류 방지
// 4. 벽 충돌 확인 및 이동 처리
let moved = false; // 이동 성공 여부 플래그
if (dx === 1 && !currentCell.walls[1]) { player.x++; moved = true; } // 오른쪽 이동 (벽 없으면)
if (dx === -1 && !currentCell.walls[3]) { player.x--; moved = true; } // 왼쪽 이동 (벽 없으면)
if (dy === 1 && !currentCell.walls[2]) { player.y++; moved = true; } // 아래쪽 이동 (벽 없으면)
if (dy === -1 && !currentCell.walls[0]) { player.y--; moved = true; } // 위쪽 이동 (벽 없으면)
// 5. 이동 성공 시 후처리
if (moved) {
// 이동한 위치를 moveStack에 추가 (되돌아가기 용도)
moveStack.push({ x: player.x, y: player.y });
// 이동한 위치를 visitedCells Set에 추가 (방문 기록, 글자 색 변경 용도)
visitedCells.add(`${player.x},${player.y}`);
draw(); // 변경된 상태를 캔버스에 다시 그림
}
}
/**
* 플레이어의 마지막 이동을 취소하는 함수 (되돌아가기).
* Backspace 키 입력 시 호출됩니다.
* moveStack과 visitedCells를 업데이트하고 화면을 다시 그립니다.
*/
function undoMove() {
// 스택에 2개 이상의 위치가 있어야 되돌아갈 수 있음 (시작 위치는 남겨야 함)
if (moveStack.length > 1) {
// 1. 현재 위치(되돌아가기 전 위치)를 visitedCells에서 제거
// -> 이 칸의 글자가 다시 검은색으로 그려지도록 함.
visitedCells.delete(`${player.x},${player.y}`);
// 2. moveStack에서 마지막 위치(현재 위치) 제거
moveStack.pop();
// 3. 스택의 새로운 마지막 위치(이전 위치)로 플레이어 좌표 업데이트
let prev = moveStack[moveStack.length - 1];
player.x = prev.x;
player.y = prev.y;
// 4. 화면 다시 그리기 (수정된 visitedCells 상태 반영)
draw();
}
}
// --- 타이머 관련 함수 ---
/**
* 게임 경과 시간 타이머를 시작하는 함수.
* 1초마다 경과 시간을 계산하여 화면에 업데이트합니다.
*/
function startTimer() {
timerStart = Date.now(); // 현재 시간 기록 (밀리초 단위)
clearInterval(interval); // 이전에 실행 중이던 타이머가 있다면 중지
// 0.5초(500ms)마다 함수 실행 (더 부드러운 업데이트를 위해 1초보다 짧게 설정 가능)
interval = setInterval(() => {
let now = Date.now();
let sec = Math.floor((now - timerStart) / 1000); // 경과 시간 (초) 계산
document.getElementById("timer").innerText = `⏱ 경과 시간: ${sec}초`; // 화면 업데이트
}, 500);
}
function stopTimer() {
clearInterval(interval);
document.getElementById("timer").innerText = '⏱ 경과 시간: 0초';
}
// --- 입력 처리 관련 함수 ---
/**
* 모바일 환경을 위한 터치 입력 처리 설정 함수.
* 터치 시작점과 끝점의 좌표 차이를 이용해 상하좌우 스와이프 방향을 감지하고 move 함수를 호출합니다.
*/
function setupTouchControls() {
let startX, startY; // 터치 시작 좌표
// 터치 시작 시 좌표 기록
canvas.addEventListener("touchstart", e => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
});
// 터치 종료 시 이동 방향 계산 및 move 호출
canvas.addEventListener("touchend", e => {
let dx = e.changedTouches[0].clientX - startX; // x축 이동량
let dy = e.changedTouches[0].clientY - startY; // y축 이동량
let threshold = 30; // 최소 이동 거리 (짧은 터치는 무시)
// 가로 이동량이 세로 이동량보다 크면 좌우 이동
if (Math.abs(dx) > Math.abs(dy)) {
if (dx > threshold) move(1, 0); // 오른쪽 스와이프
else if (dx < -threshold) move(-1, 0); // 왼쪽 스와이프
}
// 세로 이동량이 가로 이동량보다 크면 상하 이동
else {
if (dy > threshold) move(0, 1); // 아래쪽 스와이프
else if (dy < -threshold) move(0, -1); // 위쪽 스와이프
}
});
}
// 키보드 입력 이벤트 리스너 설정 (전체 문서에 적용)
document.addEventListener("keydown", function (event) {
// 눌린 키에 따라 move 또는 undoMove 함수 호출
if (event.key === "ArrowUp") move(0, -1);
if (event.key === "ArrowDown") move(0, 1);
if (event.key === "ArrowLeft") move(-1, 0);
if (event.key === "ArrowRight") move(1, 0);
if (event.key === "Backspace") undoMove(); // Backspace 키로 되돌아가기
});
// --- 정답 확인 및 경로 표시 함수 ---
/**
* 사용자가 입력한 답과 실제 정답을 비교하는 함수.
* 결과를 화면에 표시하고, 오답 시 흔들림 효과를 줍니다.
*/
function checkAnswer() {
const input = document.getElementById("answerInput").value.trim(); // 입력값 양쪽 공백 제거
if (input === answer) { // 정답일 경우
clearInterval(interval); // 타이머 멈춤
document.getElementById("resultMsg").innerText = `정답입니다! 🎯`; // 성공 메시지 표시
} else { // 오답일 경우
document.body.classList.add("shake"); // body 요소에 shake 클래스 추가 (CSS 애니메이션 실행)
// 0.3초 후 shake 클래스 제거 (애니메이션 종료 후 원래 상태로)
setTimeout(() => document.body.classList.remove("shake"), 300);
document.getElementById("resultMsg").innerText = "오답입니다. 다시 시도하세요."; // 실패 메시지 표시
}
}
/**
* 정답 경로를 화면에 표시하는 함수.
*/
function showAnswerPath() {
showPath = true; // 경로 표시 플래그 설정
draw(); // 변경된 플래그를 반영하여 다시 그리기
}
/**
* 화면에 표시된 정답 경로를 숨기는 함수.
*/
function hideAnswerPath() {
showPath = false; // 경로 표시 플래그 해제
draw(); // 변경된 플래그를 반영하여 다시 그리기
}
/**
* 미로의 빈 공간(정답 경로, 출발/도착 제외)에
* 현재 레벨의 정답 문장에서 가져온 무작위 글자(미끼 글자)를 배치하는 함수.
*/
function placeDecoyLetters() {
const occupiedCoords = new Set(); // 이미 글자(정답, 출발, 도착)가 있는 좌표 저장
// 기존 letters 배열에 있는 모든 좌표를 Set에 추가 (빠른 조회를 위해)
letters.forEach(l => occupiedCoords.add(`${l.x},${l.y}`));
const availableCells = []; // 미끼 글자를 배치할 수 있는 빈 셀 목록
// 모든 그리드 셀을 순회
for (let j = 0; j < ROWS; j++) {
for (let i = 0; i < COLS; i++) {
// 현재 셀 좌표가 occupiedCoords에 없으면 (즉, 비어 있으면)
if (!occupiedCoords.has(`${i},${j}`)) {
availableCells.push({ x: i, y: j }); // 사용 가능한 셀 목록에 추가
}
}
}
// 미끼 글자 수 결정 (예: 정답 길이의 절반 또는 사용 가능한 셀의 10% 등, 최대치는 빈 셀 수)
// 여기서는 정답 길이의 절반 정도로 설정하되, 빈 셀 수보다는 많지 않게 합니다.
const numDecoys = availableCells.length - answer.length * 2;
// const numDecoys = Math.min(Math.floor(availableCells.length * 0.5), answer.length);
// 사용 가능한 셀 목록을 무작위로 섞음 (Fisher-Yates Shuffle)
for (let i = availableCells.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[availableCells[i], availableCells[j]] = [availableCells[j], availableCells[i]];
}
// 결정된 수만큼 미끼 글자 배치
for (let k = 0; k < numDecoys; k++) {
const cell = availableCells[k]; // 섞인 목록에서 셀 하나 선택
// 정답 문자열에서 무작위로 글자 하나 선택
const randomChar = answer[Math.floor(Math.random() * answer.length)];
// path 배열에서 현재 cell의 좌표(x, y)와 동일한 좌표(i, j)를 가진 요소가 있는지 확인
if (path.find(p => p.i === cell.x && p.j === cell.y)) {
continue; // 경로 상에 있는 좌표라면 이 셀에는 미끼 글자를 놓지 않고 다음 셀로 넘어감
}
// 미끼 글자 객체 생성 및 letters 배열에 추가
letters.push({
x: cell.x,
y: cell.y,
char: randomChar,
isAnswer: false, // 정답 경로 글자가 아님
isDecoy: true // 미끼 글자임을 표시 (선택적, 나중에 스타일링 등에 활용 가능)
});
}
}
</script>
</body>
</html>
연관코드
-
CONCLUSION
미로게임
삭제권한 확인 중...
수정 권한 확인 중...