/* * 파일명: ladder-game.js * 위치: /js/ladder-game.js * 기능: 사다리타기 게임 로직 구현 (꽝 찾기 버전) * 작성일: 2025-04-18 * 수정일: 2025-04-18 */ $(document).ready(function() { // =================================== // 전역 변수 선언 // =================================== let participantCount = 5; let participants = []; let losers = []; // 꽝 위치 저장 배열 let ladderLines = []; let horizontalLineCount = 8; // 가로선 수 감소 let canvas, ctx; let lineWidth = 2; let startY = 60; let endY = 0; // 동적으로 계산될 예정 let canvasWidth = 0; // 동적으로 계산될 예정 let canvasHeight = 0; // 동적으로 계산될 예정 let verticalGap = 0; // 동적으로 계산될 예정 let animationId = null; let currentAnimation = null; let animationSpeed = 5; // 애니메이션 속도 let pathMap = {}; // 각 참가자의 경로 맵을 저장 let ladderColors = ['#3f6ad8', '#3ac47d', '#f7b924', '#d92550', '#11c5db']; // 컬러풀한 사다리 경로 색상 let completedParticipants = []; // 게임을 마친 참가자 인덱스 저장 let instructionTimer = null; // 안내 문구 타이머 let resultParticipants = []; // 각 결과 위치에 도달한 참가자 인덱스 // 화면 크기에 따른 설정 let isMobile = window.innerWidth < 768; // =================================== // 초기화 함수 // =================================== function init() { // 참가자 수 기본값 설정 participantCount = parseInt($('#participant-count').val()) || 5; // 캔버스 초기화 canvas = document.getElementById('ladder-canvas'); ctx = canvas.getContext('2d'); // 캔버스 크기 설정 updateCanvasSize(); // 이벤트 리스너 등록 (jQuery 방식으로 통일) $('#set-count').on('click', updateParticipantCount); $('#draw-ladder').on('click', drawLadder); $('#start-game').on('click', startGame); $('#reset-game').on('click', resetGame); // 이벤트 문제 디버깅을 위한 로그 console.log('이벤트 리스너 등록 완료'); // 이벤트 연결 확인 로그 $('#start-game').on('click', function() { console.log('게임 시작 버튼 클릭됨'); }); // 윈도우 리사이즈 이벤트 $(window).resize(function() { isMobile = window.innerWidth < 768; if (canvasWidth > 0) { // 캔버스가 이미 초기화된 경우에만 실행 updateCanvasSize(); if ($('#start-game').is(':visible')) { // 사다리가 이미 그려진 상태면 다시 그리기 clearCanvas(); drawLadderOnCanvas(); drawParticipantsAndLosers(); } } }); // 초기 참가자 입력란 생성 updateParticipantInputs(); } // =================================== // 게임 시작 // =================================== function startGame() { console.log('startGame 함수 실행됨'); // 꽝이 설정되었는지 확인 if (losers.length === 0) { alert('최소 한 개 이상의 꽝을 선택해주세요.'); return; } // 참가자 선택 UI 표시 showParticipantSelection(); } // =================================== // 참가자 수 업데이트 // =================================== function updateParticipantCount() { const count = parseInt($('#participant-count').val()); if (count >= 2 && count <= 10) { participantCount = count; updateParticipantInputs(); } else { alert('참가자 수는 2명에서 10명 사이로 입력해주세요.'); } } // =================================== // updateParticipantInputs 함수 수정 - 모바일 최적화 // =================================== function updateParticipantInputs() { const $participantNames = $('#participant-names'); const $loserSelection = $('#loser-selection'); // 기존 입력란 삭제 $participantNames.empty(); $loserSelection.empty(); // 참가자 입력란 생성 for (let i = 0; i < participantCount; i++) { const participantInput = `
`; $participantNames.append(participantInput); } // 꽝 선택 UI 생성 (모바일 최적화) let loserButtonsHtml = ''; // 참가자 수가 많을 경우 버튼 그리드 조정 if (participantCount > 6 && isMobile) { // 3열로 배치 for (let i = 0; i < participantCount; i++) { loserButtonsHtml += ` `; // 3개마다 줄바꿈 추가 if ((i + 1) % 3 === 0) { loserButtonsHtml += '
'; } } } else { // 기존 방식대로 표시 for (let i = 0; i < participantCount; i++) { loserButtonsHtml += ` `; } } const loserSelectionUI = `
꽝 위치 클릭으로 선택
${loserButtonsHtml}
`; $loserSelection.html(loserSelectionUI); // 꽝 토글 버튼에 이벤트 추가 $('.loser-toggle').on('click', function() { const index = parseInt($(this).data('index')); if ($(this).hasClass('active')) { // 꽝 해제 $(this).removeClass('active'); losers = losers.filter(idx => idx !== index); } else { // 꽝 설정 $(this).addClass('active'); if (!losers.includes(index)) { losers.push(index); } } console.log('현재 꽝 위치:', losers); }); } // =================================== // 모바일 환경 개선 - 캔버스 크기 업데이트 함수 수정 // =================================== function updateCanvasSize() { const containerWidth = $('.ladder-container').width(); const containerHeight = $('.ladder-container').height(); // 모바일 여부 확인 및 참가자 수에 따른 조정 isMobile = window.innerWidth < 768; // 캔버스 크기 설정 (컨테이너보다 약간 작게) canvasWidth = containerWidth - 20; canvasHeight = containerHeight - 20; // 최소 크기 보장 if (isMobile) { // 모바일에서는 참가자 수에 따라 캔버스 가로 길이 늘리기 if (participantCount > 6) { // 참가자가 많은 경우 더 넓게 설정하고 가로 스크롤 허용 canvasWidth = Math.max(participantCount * 60, containerWidth - 20); $('.ladder-container').css('overflow-x', 'auto'); } else { $('.ladder-container').css('overflow-x', 'hidden'); } // 모바일에서는 세로 높이 적절히 조정 canvasHeight = Math.max(canvasHeight, 400); horizontalLineCount = 6; // 모바일에서는 가로선 수 감소 } else { // 데스크탑에서는 원래대로 canvasWidth = Math.max(canvasWidth, participantCount * 80); canvasHeight = Math.max(canvasHeight, 500); horizontalLineCount = 8; $('.ladder-container').css('overflow-x', 'auto'); } canvas.width = canvasWidth; canvas.height = canvasHeight; // 수직 간격 계산 - 모바일에서 참가자 수가 많으면 간격 줄이기 if (isMobile && participantCount > 6) { verticalGap = canvasWidth / (participantCount + 1); } else { verticalGap = canvasWidth / (participantCount + 1); } // 사다리 끝 Y좌표 설정 endY = canvasHeight - 60; } // =================================== // 사다리 그리기 // =================================== function drawLadder() { console.log('drawLadder 함수 실행됨'); // 참가자 정보 수집 collectParticipants(); // 캔버스 크기 조정 updateCanvasSize(); // 캔버스 초기화 clearCanvas(); // 사다리 생성 generateLadder(); // 모든 경로 미리 계산 calculateAllPaths(); // 사다리 그리기 drawLadderOnCanvas(); // 참가자 및 결과 표시 drawParticipantsAndLosers(); // 완료 참가자 목록 초기화 completedParticipants = []; // 결과 참가자 목록 초기화 resultParticipants = []; // 버튼 상태 변경 $('#draw-ladder').hide(); $('#start-game').show(); $('#reset-game').show(); // 게임 시작 버튼 활성화 상태 확인 console.log('게임 시작 버튼 표시 상태:', $('#start-game').is(':visible')); console.log('게임 시작 버튼 활성화 상태:', !$('#start-game').prop('disabled')); } // =================================== // 참가자 정보 수집 // =================================== function collectParticipants() { participants = []; $('.participant-name').each(function() { participants.push($(this).val() || $(this).attr('placeholder')); }); } // =================================== // 캔버스 초기화 // =================================== function clearCanvas() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 배경 패턴 그리기 drawBackgroundPattern(); } // =================================== // 배경 패턴 그리기 // =================================== function drawBackgroundPattern() { const dotSize = 1; const gapSize = 20; ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; for (let x = 0; x < canvasWidth; x += gapSize) { for (let y = 0; y < canvasHeight; y += gapSize) { ctx.beginPath(); ctx.arc(x, y, dotSize, 0, Math.PI * 2); ctx.fill(); } } } // =================================== // 사다리 생성 // =================================== function generateLadder() { ladderLines = []; // 수직선 생성 for (let i = 0; i < participantCount; i++) { ladderLines.push({ type: 'vertical', x: (i + 1) * verticalGap, y1: startY, y2: endY }); } // 가로선 생성을 위한 높이 계산 (세로 길이 증가) const stepHeight = (endY - startY) / (horizontalLineCount + 1); // 모든 세로선에 대한 연결 여부를 추적 let verticalConnected = new Array(participantCount).fill(false); // 각 층별로 가로선 생성 for (let i = 0; i < horizontalLineCount; i++) { // 각 가로선마다 높이를 명확하게 다르게 설정 const baseY = startY + stepHeight * (i + 1); // 이번 층에 가로선을 추가할 위치 선택 let positions = []; // 모든 가능한 위치 (0 ~ participantCount-2) for (let j = 0; j < participantCount - 1; j++) { positions.push(j); } // 위치 섞기 positions = shuffleArray(positions); // 최소 1개 또는 (참가자 수 - 1) / 3 개 중 작은 값만큼 가로선 생성 (가로선 수 감소) const minLines = Math.min(1, Math.floor((participantCount - 1) / 3)); let linesAdded = 0; // 선택된 위치에 가로선 추가 (각 위치마다 높이를 미세하게 다르게 설정) for (let j = 0; j < positions.length && linesAdded < minLines; j++) { const pos = positions[j]; // 각 가로선마다 높이 오프셋 추가 (j에 따라 다름) const yOffset = 5 * j; ladderLines.push({ type: 'horizontal', x1: (pos + 1) * verticalGap, x2: (pos + 2) * verticalGap, y: baseY + yOffset, thickness: Math.random() * 0.5 + 0.8 }); // 연결된 세로선 표시 verticalConnected[pos] = true; verticalConnected[pos + 1] = true; linesAdded++; } // 나머지 위치는 랜덤하게 결정 (50% 확률로 추가 - 가로선 밀도 감소) for (let j = minLines; j < positions.length; j++) { const pos = positions[j]; if (Math.random() < 0.5) { // 확률 감소 // 높이를 다양하게 설정 (이전에 추가된 가로선과 겹치지 않도록) const yOffset = 5 * (j + linesAdded); ladderLines.push({ type: 'horizontal', x1: (pos + 1) * verticalGap, x2: (pos + 2) * verticalGap, y: baseY + yOffset, thickness: Math.random() * 0.5 + 0.8 }); // 연결된 세로선 표시 verticalConnected[pos] = true; verticalConnected[pos + 1] = true; } } } // 연결되지 않은 세로선 확인하고 가로선 추가 (높이가 겹치지 않도록 주의) const extraLineY = startY + (endY - startY) * 0.85; // 일반 가로선과 겹치지 않는 높이 let extraLineOffset = 0; for (let i = 0; i < participantCount; i++) { if (!verticalConnected[i]) { if (i > 0) { // 왼쪽에 추가 (높이 오프셋 사용) ladderLines.push({ type: 'horizontal', x1: i * verticalGap, x2: (i + 1) * verticalGap, y: extraLineY + (extraLineOffset * 10), thickness: 1 }); extraLineOffset++; verticalConnected[i] = true; verticalConnected[i - 1] = true; } else if (i < participantCount - 1) { // 오른쪽에 추가 (높이 오프셋 사용) ladderLines.push({ type: 'horizontal', x1: (i + 1) * verticalGap, x2: (i + 2) * verticalGap, y: extraLineY + (extraLineOffset * 10), thickness: 1 }); extraLineOffset++; verticalConnected[i] = true; verticalConnected[i + 1] = true; } } } // 경로 계산 및 유효성 검사 calculateAllPaths(); // 유효하지 않은 경로인 경우 강제로 1:1 매핑 생성 let attempts = 0; const maxAttempts = 5; while (!isValidPaths() && attempts < maxAttempts) { console.log(`유효하지 않은 경로 발견, 강제 매핑 생성 시도 ${attempts + 1}`); forceValidLadder(); calculateAllPaths(); attempts++; } // 최종 검증 if (!isValidPaths()) { console.error("최종적으로 유효한 경로를 생성하지 못했습니다. 완전히 새로운 방법으로 시도합니다."); createSimpleLadder(); calculateAllPaths(); } // 배열 섞기 함수 function shuffleArray(array) { const newArray = [...array]; // 원본 배열을 변경하지 않기 위해 복사 for (let i = newArray.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; } return newArray; } // 가장 단순한 사다리 생성 방법 (마지막 수단) function createSimpleLadder() { // 기존 가로선 삭제 ladderLines = ladderLines.filter(line => line.type === 'vertical'); // 매우 단순한 방식: 각 참가자가 하나의 가로선으로 연결되어 옆으로 이동하게 함 // 결과를 랜덤하게 섞기 위한 배열 let resultIndices = []; for (let i = 0; i < participantCount; i++) { resultIndices.push(i); } // 결과 인덱스 섞기 resultIndices = shuffleArray(resultIndices); // 변환 맵 생성 (인덱스 i는 resultIndices[i]로 이동해야 함) const transformMap = {}; for (let i = 0; i < participantCount; i++) { transformMap[i] = resultIndices[i]; } // 순차적으로 변환을 수행하기 위한 단계적 가로선 추가 const sectionCount = 6; // 6단계로 나누어 진행 for (let section = 0; section < sectionCount; section++) { // 현재 단계의 Y 위치 계산 const sectionY = startY + (endY - startY) * (section + 1) / (sectionCount + 1); // 각 단계에서 1-2개의 가로선만 추가 (너무 많은 가로선이 한 번에 추가되지 않도록) let addedInThisSection = 0; const maxLinesPerSection = 2; // 각 위치에 대해 랜덤하게 가로선 추가 시도 let positions = shuffleArray(Array.from({length: participantCount - 1}, (_, i) => i)); for (const pos of positions) { if (addedInThisSection >= maxLinesPerSection) break; // 60% 확률로 가로선 추가 if (Math.random() < 0.6) { // 가로선 추가시 명확하게 다른 높이 사용 ladderLines.push({ type: 'horizontal', x1: (pos + 1) * verticalGap, x2: (pos + 2) * verticalGap, y: sectionY + (addedInThisSection * 15), // 15픽셀 간격으로 위치 조절 thickness: 1 }); addedInThisSection++; } } } } // 유효한 사다리 강제 생성 함수 function forceValidLadder() { // 기존 가로선 삭제 ladderLines = ladderLines.filter(line => line.type === 'vertical'); // 결과를 랜덤하게 섞기 위한 배열 let resultIndices = []; for (let i = 0; i < participantCount; i++) { resultIndices.push(i); } // 결과 인덱스 섞기 resultIndices = shuffleArray(resultIndices); // 5개의 서로 다른 높이 레벨에 가로선 생성 const levels = 5; for (let level = 0; level < levels; level++) { const levelY = startY + ((endY - startY) * (level + 1)) / (levels + 1); // 각 레벨에서는 최대 2개의 가로선만 생성 (겹치지 않도록) let lineCount = 0; const maxLinesPerLevel = 2; // 가능한 모든 위치 배열 생성 (0부터 participantCount-2까지) let positions = shuffleArray(Array.from({length: participantCount - 1}, (_, i) => i)); // 선택된 위치에 가로선 추가 for (const pos of positions) { if (lineCount >= maxLinesPerLevel) break; // 높이를 확실히 다르게 (각 가로선마다 20픽셀 간격) const y = levelY + (lineCount * 20); ladderLines.push({ type: 'horizontal', x1: (pos + 1) * verticalGap, x2: (pos + 2) * verticalGap, y: y, thickness: 1 }); lineCount++; } } } } // =================================== // 경로 유효성 검사 // =================================== function isValidPaths() { // 각 참가자가 도달하는 결과 인덱스를 저장 const resultSet = new Set(); // 각 참가자의 결과 확인 for (let i = 0; i < participantCount; i++) { const resultIndex = pathMap[i]; if (resultIndex === undefined || resultIndex < 0 || resultIndex >= participantCount) { console.log(`참가자 ${i+1}의 결과 인덱스가 유효하지 않음: ${resultIndex}`); return false; } // 이미 다른 참가자가 같은 결과에 도달하는 경우 if (resultSet.has(resultIndex)) { console.log(`참가자 ${i+1}의 결과 인덱스 ${resultIndex}가 중복됨`); return false; } resultSet.add(resultIndex); } // 모든 결과가 사용되었는지 확인 const valid = resultSet.size === participantCount; console.log(`경로 유효성 검사 결과: ${valid ? '유효함' : '유효하지 않음'}`); return valid; } // =================================== // 모든 경로 미리 계산 // =================================== function calculateAllPaths() { pathMap = {}; for (let i = 0; i < participantCount; i++) { const resultIndex = trackPath(i); pathMap[i] = resultIndex; console.log(`참가자 ${i+1} -> 결과 ${resultIndex+1}`); } } // =================================== // 게임 시작 후 애니메이션 완료 처리 // =================================== function finishAnimation(startIndex, resultIndex) { // 해당 참가자를 완료 목록에 추가 if (!completedParticipants.includes(startIndex)) { completedParticipants.push(startIndex); } // 결과 위치에 참가자 인덱스 저장 (꽝이 아닌 경우만) if (!losers.includes(resultIndex)) { resultParticipants[resultIndex] = startIndex; } // 꽝 여부 확인 const isLoser = losers.includes(resultIndex); // 애니메이션 효과 if (isLoser) { showLoserAnimation(resultIndex); } // 마지막 경로와 연결 상태 유지를 위해 다시 그리기 setTimeout(function() { clearCanvas(); drawLadderOnCanvas(); drawParticipantsAndLosers(); // 모든 참가자가 게임을 완료했는지 확인 if (completedParticipants.length >= participantCount) { // 게임 종료 메시지 표시 showInstructionMessage("게임 완료. '다시하기'를 눌러 새 게임 시작."); } else { // 게임 완료 후 다시 참가자 선택 UI 표시 showParticipantSelection(); } }, isLoser ? 3000 : 1000); // 꽝일 경우 애니메이션 시간 확보 } // =================================== // 사다리 그리기 // =================================== function drawLadderOnCanvas() { // 그라데이션 배경 (예쁜 효과) const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeight); gradient.addColorStop(0, 'rgba(245, 247, 250, 0.8)'); gradient.addColorStop(1, 'rgba(255, 255, 255, 0.8)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, canvasWidth, canvasHeight); // 배경 패턴 그리기 drawBackgroundPattern(); // 사다리 테두리 그리기 ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)'; ctx.lineWidth = 10; ctx.strokeRect(5, 5, canvasWidth - 10, canvasHeight - 10); // 모든 세로선 먼저 그리기 ctx.strokeStyle = '#ddd'; ctx.lineWidth = lineWidth; ladderLines.filter(line => line.type === 'vertical').forEach(line => { ctx.beginPath(); ctx.moveTo(line.x, line.y1); ctx.lineTo(line.x, line.y2); ctx.stroke(); }); // 모든 가로선 그리기 ladderLines.filter(line => line.type === 'horizontal').forEach(line => { // 라인 두께 설정 (기본값 유지 또는 지정된 값 사용) ctx.lineWidth = line.thickness ? lineWidth * line.thickness : lineWidth; ctx.beginPath(); ctx.moveTo(line.x1, line.y); ctx.lineTo(line.x2, line.y); ctx.stroke(); }); // 테두리 라인 두께 원상복구 ctx.lineWidth = lineWidth; } // =================================== // 참가자 및 꽝 위치 표시 함수 수정 - 모바일 최적화 // =================================== function drawParticipantsAndLosers() { // 상단과 하단 배경 (그림자 효과) const topGradient = ctx.createLinearGradient(0, 0, 0, startY); topGradient.addColorStop(0, 'rgba(255, 255, 255, 0.9)'); topGradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = topGradient; ctx.fillRect(0, 0, canvasWidth, startY); const bottomGradient = ctx.createLinearGradient(0, endY, 0, canvasHeight); bottomGradient.addColorStop(0, 'rgba(255, 255, 255, 0)'); bottomGradient.addColorStop(1, 'rgba(255, 255, 255, 0.9)'); ctx.fillStyle = bottomGradient; ctx.fillRect(0, endY, canvasWidth, canvasHeight - endY); // 참가자 이름 표시 (상단) - 이름과 원 사이 간격 늘림 for (let i = 0; i < participantCount; i++) { const x = (i + 1) * verticalGap; // 모바일에서 참가자 수가 많은 경우 이름 스타일 조정 if (isMobile && participantCount > 6) { // 이름을 작게 표시하거나 생략 ctx.fillStyle = '#333'; ctx.font = '10px Arial'; // 더 작은 폰트 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // 이름이 긴 경우 줄임 let displayName = participants[i]; if (displayName.length > 3) { displayName = displayName.substring(0, 3) + '..'; } ctx.fillText(displayName, x, startY - 40); // 위치 조정 } else { // 기존 스타일 유지 ctx.fillStyle = '#333'; ctx.font = '12px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(participants[i], x, startY - 50); } // 원 크기 조정 - 모바일에서 참가자 수가 많은 경우 const circleRadius = (isMobile && participantCount > 6) ? 18 : 25; // 게임을 마친 참가자인지 확인 const isCompleted = completedParticipants.includes(i); // 원 배경 그리기 if (isCompleted) { // 게임을 마친 참가자는 회색으로 표시 ctx.fillStyle = '#e0e0e0'; } else { ctx.fillStyle = '#f8f9fa'; } ctx.beginPath(); ctx.arc(x, startY - 15, circleRadius, 0, Math.PI * 2); ctx.fill(); // 원 테두리 if (isCompleted) { ctx.strokeStyle = '#aaa'; // 게임 완료 참가자 - 어두운 테두리 } else { ctx.strokeStyle = '#ddd'; } ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(x, startY - 15, circleRadius, 0, Math.PI * 2); ctx.stroke(); // 번호 표시 - 모바일에서 참가자 수가 많은 경우 더 작게 if (isCompleted) { ctx.fillStyle = '#888'; // 회색 번호 } else { ctx.fillStyle = '#3f6ad8'; // 파란색 번호 } const fontSize = (isMobile && participantCount > 6) ? 12 : 14; ctx.font = `bold ${fontSize}px Arial`; ctx.fillText((i + 1).toString(), x, startY - 15); // 게임 완료 표시 (체크 마크) - 모바일에서 참가자 수가 많은 경우 크기 조정 if (isCompleted) { const markSize = (isMobile && participantCount > 6) ? 6 : 8; const resultIndex = pathMap[i]; const isLoser = losers.includes(resultIndex); if (isLoser) { // 꽝인 경우 X 마크 ctx.strokeStyle = '#d92550'; ctx.lineWidth = 2; // X 그리기 ctx.beginPath(); ctx.moveTo(x - markSize, startY - 15 - markSize); ctx.lineTo(x + markSize, startY - 15 + markSize); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x + markSize, startY - 15 - markSize); ctx.lineTo(x - markSize, startY - 15 + markSize); ctx.stroke(); } else { // 통과인 경우 체크 마크 ctx.strokeStyle = '#3ac47d'; ctx.lineWidth = 2; if (isMobile && participantCount > 6) { // 작은 체크 마크 ctx.beginPath(); ctx.moveTo(x - 8, startY - 15); ctx.lineTo(x - 3, startY - 10); ctx.lineTo(x + 8, startY - 20); ctx.stroke(); } else { // 기존 체크 마크 ctx.beginPath(); ctx.moveTo(x - 10, startY - 15); ctx.lineTo(x - 5, startY - 10); ctx.lineTo(x + 10, startY - 25); ctx.stroke(); } } } } // 결과 표시 (하단) - 모바일 환경 최적화 for (let i = 0; i < participantCount; i++) { const x = (i + 1) * verticalGap; // 모바일에서 참가자 수가 많은 경우 박스 크기 조정 const boxWidth = (isMobile && participantCount > 6) ? 50 : 80; // 꽝 여부에 따라 스타일 변경 const isLoser = losers.includes(i); // 결과 배경 박스 if (isLoser) { // 꽝 박스 - 빨간색 배경 ctx.fillStyle = '#ffeeee'; ctx.beginPath(); ctx.roundRect(x - boxWidth/2, endY + 5, boxWidth, 30, 5); ctx.fill(); // 꽝 박스 테두리 ctx.strokeStyle = '#ffcccc'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x - boxWidth/2, endY + 5, boxWidth, 30, 5); ctx.stroke(); // 꽝 텍스트 ctx.fillStyle = '#d92550'; const fontSize = (isMobile && participantCount > 6) ? 12 : 14; ctx.font = `bold ${fontSize}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText("꽝", x, endY + 20); } else { // 일반 결과 박스 (빈칸 또는 참가자 번호) ctx.fillStyle = '#f8f9fa'; ctx.beginPath(); ctx.roundRect(x - boxWidth/2, endY + 5, boxWidth, 30, 5); ctx.fill(); // 결과 박스 테두리 ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x - boxWidth/2, endY + 5, boxWidth, 30, 5); ctx.stroke(); // 참가자 번호 표시 (해당 위치에 도달한 참가자가 있는 경우) const participantIndex = resultParticipants[i]; if (participantIndex !== undefined) { // 참가자 번호 표시 ctx.fillStyle = '#3ac47d'; // 초록색 const fontSize = (isMobile && participantCount > 6) ? 12 : 14; ctx.font = `bold ${fontSize}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText((participantIndex + 1) + "번", x, endY + 20); } // 빈칸인 경우 아무것도 표시하지 않음 } } // 게임을 마친 참가자의 경로 표시 for (let i = 0; i < completedParticipants.length; i++) { const participantIdx = completedParticipants[i]; const resultIdx = pathMap[participantIdx]; // 결과가 유효한지 확인 if (resultIdx !== undefined) { const participantX = (participantIdx + 1) * verticalGap; const resultX = (resultIdx + 1) * verticalGap; // 참가자에서 결과로 연결하는 선 ctx.beginPath(); ctx.moveTo(participantX, startY); ctx.lineTo(resultX, endY); const isLoser = losers.includes(resultIdx); // 꽝 여부에 따라 선 색상 변경 if (isLoser) { ctx.strokeStyle = 'rgba(217, 37, 80, 0.2)'; // 꽝인 경우 빨간색 계열 } else { ctx.strokeStyle = 'rgba(58, 196, 125, 0.2)'; // 통과인 경우 초록색 계열 } ctx.lineWidth = 2; ctx.setLineDash([5, 3]); // 점선 효과 ctx.stroke(); ctx.setLineDash([]); // 점선 효과 해제 } } } // =================================== // 참가자 선택 UI 표시 // =================================== function showParticipantSelection() { clearCanvas(); drawLadderOnCanvas(); drawParticipantsAndLosers(); // 참가자 위치에 선택 가능한 효과 표시 for (let i = 0; i < participantCount; i++) { const x = (i + 1) * verticalGap; // 이미 게임을 마친 참가자인지 확인 const isCompleted = completedParticipants.includes(i); if (!isCompleted) { // 선택 가능한 참가자 강조 표시 (원) ctx.fillStyle = '#4285f4'; // 구글 블루 색상 ctx.beginPath(); ctx.arc(x, startY - 15, 25, 0, Math.PI * 2); ctx.fill(); // 원 테두리 ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, startY - 15, 25, 0, Math.PI * 2); ctx.stroke(); // 번호 표시 (흰색) ctx.fillStyle = '#fff'; ctx.font = 'bold 14px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText((i + 1).toString(), x, startY - 15); } } // 사용자에게 안내 메시지 표시 (사다리 판 위에) showInstructionMessage("참가자 번호를 클릭하여 게임을 시작하세요"); // 클릭 이벤트 리스너 추가 (중복 방지) canvas.removeEventListener('click', handleParticipantClick); canvas.addEventListener('click', handleParticipantClick); // 애니메이션 효과 중지 (문제 발생 방지) if (selectionAnimationId) { cancelAnimationFrame(selectionAnimationId); selectionAnimationId = null; } } // =================================== // 안내 메시지 표시 함수 (사다리 판 위에 오버레이) // =================================== function showInstructionMessage(message) { // 기존 타이머 제거 if (instructionTimer) { clearTimeout(instructionTimer); } // 메시지 배경 const messageY = canvasHeight / 2 - 100; // 사다리 판 위쪽에 표시 // 배경 박스 (반투명 진한 파란색 배경) ctx.fillStyle = 'rgba(25, 118, 210, 0.8)'; // 더 진한 파란색 배경 const boxPadding = 20; // 텍스트 주변 여백 const boxHeight = 40; // 배경 박스 그리기 ctx.beginPath(); ctx.roundRect(canvasWidth / 2 - 200, messageY - boxHeight/2, 400, boxHeight, 8); ctx.fill(); // 메시지 텍스트 (흰색으로 변경하여 가시성 향상) ctx.fillStyle = 'white'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(message, canvasWidth / 2, messageY); // 2초 후 메시지 사라짐 (시간 단축) instructionTimer = setTimeout(() => { clearCanvas(); drawLadderOnCanvas(); drawParticipantsAndLosers(); }, 2000); } // =================================== // 선택 가능한 참가자 애니메이션 // =================================== let selectionAnimationId = null; let selectionAnimationFrame = 0; function animateSelectionCircle(x, y) { if (selectionAnimationId) { cancelAnimationFrame(selectionAnimationId); } function animate() { selectionAnimationFrame += 0.05; const scale = 1 + Math.sin(selectionAnimationFrame) * 0.1; // 선택 가능한 참가자들만 다시 그리기 for (let i = 0; i < participantCount; i++) { const posX = (i + 1) * verticalGap; // 깜빡이는 효과 ctx.fillStyle = `rgba(63, 106, 216, ${0.2 + Math.sin(selectionAnimationFrame) * 0.1})`; ctx.beginPath(); ctx.arc(posX, startY - 15, 30 * scale, 0, Math.PI * 2); ctx.fill(); } selectionAnimationId = requestAnimationFrame(animate); } animate(); } // =================================== // 경로 추적 (참가자 시작 인덱스에서 결과 인덱스 찾기) // =================================== function trackPath(startIndex) { let currentX = (startIndex + 1) * verticalGap; let currentY = startY; // 사다리 아래로 내려가면서 좌우 이동 추적 while (currentY < endY) { // 현재 위치에서 가장 가까운 가로선 찾기 let closestHorizontalLine = null; let minDistance = Number.MAX_VALUE; // 모든 가로선을 검사 for (const line of ladderLines) { if (line.type === 'horizontal' && line.y > currentY && line.y < endY) { // 가로선의 왼쪽 끝과 현재 위치가 일치하는지 확인 if (Math.abs(line.x1 - currentX) < 0.5) { const distance = line.y - currentY; if (distance < minDistance) { minDistance = distance; closestHorizontalLine = line; } } // 가로선의 오른쪽 끝과 현재 위치가 일치하는지 확인 else if (Math.abs(line.x2 - currentX) < 0.5) { const distance = line.y - currentY; if (distance < minDistance) { minDistance = distance; closestHorizontalLine = line; } } } } // 가장 가까운 가로선을 따라 이동 if (closestHorizontalLine) { // 세로선을 따라 가로선까지 이동 currentY = closestHorizontalLine.y; // 가로선을 따라 이동 if (Math.abs(closestHorizontalLine.x1 - currentX) < 0.5) { currentX = closestHorizontalLine.x2; } else { currentX = closestHorizontalLine.x1; } } else { // 더 이상 가로선이 없으면 끝까지 내려감 currentY = endY; } } // 결과 인덱스 찾기 for (let i = 0; i < participantCount; i++) { const x = (i + 1) * verticalGap; if (Math.abs(x - currentX) < 0.5) { return i; } } console.error(`참가자 ${startIndex+1}의 결과를 찾을 수 없습니다. currentX: ${currentX}`); return -1; } // =================================== // 참가자 클릭 처리 함수 수정 - 모바일 터치 개선 // =================================== function handleParticipantClick(e) { const rect = canvas.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; // 모바일에서 참가자 수가 많은 경우 클릭 영역 조정 const circleRadius = (isMobile && participantCount > 6) ? 18 : 25; const clickRadius = circleRadius + 5; // 터치 영역 약간 확장 // 클릭한 위치가 참가자 원 내부인지 확인 for (let i = 0; i < participantCount; i++) { const x = (i + 1) * verticalGap; const y = startY - 15; const distance = Math.sqrt(Math.pow(clickX - x, 2) + Math.pow(clickY - y, 2)); if (distance <= clickRadius) { // 이미 게임을 마친 참가자인지 확인 if (completedParticipants.includes(i)) { // 알림 메시지 표시 (사다리 판에 메시지 표시) showInstructionMessage(`${i+1}번 참가자는 이미 게임을 완료했습니다.`); return; } // 선택 애니메이션 중지 if (selectionAnimationId) { cancelAnimationFrame(selectionAnimationId); selectionAnimationId = null; } // 클릭 이벤트 리스너 제거 (애니메이션 중 중복 클릭 방지) canvas.removeEventListener('click', handleParticipantClick); // 애니메이션 시작 animateLadder(i); break; } } } // =================================== // 사다리 애니메이션 // =================================== function animateLadder(startIndex) { clearCanvas(); drawLadderOnCanvas(); drawParticipantsAndLosers(); // 현재 위치 let currentX = (startIndex + 1) * verticalGap; let currentY = startY; // 경로 추적을 위한 배열 let pathPoints = [{x: currentX, y: currentY}]; // 애니메이션 취소 (이전 애니메이션이 있을 경우) if (animationId) { cancelAnimationFrame(animationId); } // 경로 계산을 위한 변수들 let remainingPath = []; let currentPathIndex = 0; // 선택한 참가자 색상 const pathColor = ladderColors[startIndex % ladderColors.length]; // 전체 경로를 미리 계산 calculatePathPoints(); function calculatePathPoints() { let x = currentX; let y = currentY; remainingPath = [{x, y}]; while (y < endY) { // 현재 위치에서 가장 가까운 가로선 찾기 let closestHorizontalLine = null; let minDistance = Number.MAX_VALUE; for (const line of ladderLines) { if (line.type === 'horizontal' && line.y > y && line.y < endY && ((Math.abs(line.x1 - x) < 0.1) || (Math.abs(line.x2 - x) < 0.1))) { const distance = line.y - y; if (distance < minDistance) { minDistance = distance; closestHorizontalLine = line; } } } // 가장 가까운 가로선을 따라 이동 if (closestHorizontalLine) { // 세로선을 따라 가로선까지 이동 remainingPath.push({x: x, y: closestHorizontalLine.y}); y = closestHorizontalLine.y; // 가로선을 따라 이동 if (Math.abs(closestHorizontalLine.x1 - x) < 0.1) { remainingPath.push({x: closestHorizontalLine.x2, y: y}); x = closestHorizontalLine.x2; } else { remainingPath.push({x: closestHorizontalLine.x1, y: y}); x = closestHorizontalLine.x1; } } else { // 더 이상 가로선이 없으면 끝까지 내려감 remainingPath.push({x: x, y: endY}); y = endY; } } } // 애니메이션 함수 function animate() { // 캔버스 초기화 및 사다리 다시 그리기 clearCanvas(); drawLadderOnCanvas(); drawParticipantsAndLosers(); // 경로 그리기 ctx.strokeStyle = pathColor; ctx.lineWidth = 3; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(pathPoints[0].x, pathPoints[0].y); for (let i = 1; i < pathPoints.length; i++) { ctx.lineTo(pathPoints[i].x, pathPoints[i].y); } ctx.stroke(); // 현재 위치 표시 (움직이는 원) ctx.fillStyle = pathColor; ctx.beginPath(); ctx.arc(currentX, currentY, 6, 0, Math.PI * 2); ctx.fill(); // 다음 목표 지점으로 이동 const target = remainingPath[currentPathIndex]; // 목표를 향해 이동 let reachedTarget = false; if (Math.abs(target.x - currentX) > 0.1) { // 가로 이동 if (target.x > currentX) { currentX = Math.min(currentX + animationSpeed, target.x); } else { currentX = Math.max(currentX - animationSpeed, target.x); } } else if (Math.abs(target.y - currentY) > 0.1) { // 세로 이동 if (target.y > currentY) { currentY = Math.min(currentY + animationSpeed, target.y); } else { currentY = Math.max(currentY - animationSpeed, target.y); } } else { // 목표 지점에 도달 reachedTarget = true; } // 현재 위치 기록 pathPoints.push({x: currentX, y: currentY}); // 목표 지점에 도달했을 때 if (reachedTarget) { currentPathIndex++; // 모든 경로를 탐색했다면 if (currentPathIndex >= remainingPath.length) { // 결과 표시 const resultIndex = pathMap[startIndex]; if (resultIndex === undefined || resultIndex < 0 || resultIndex >= participantCount) { console.error(`참가자 ${startIndex+1}의 결과 인덱스가 유효하지 않음: ${resultIndex}`); // 오류 처리 - 재시도 또는 강제 종료 resetGame(); return; } // 애니메이션 완료 처리 finishAnimation(startIndex, resultIndex); // 애니메이션 중지 cancelAnimationFrame(animationId); return; } } // 다음 프레임 요청 animationId = requestAnimationFrame(animate); } // 애니메이션 시작 animate(); } // =================================== // 꽝 애니메이션 // =================================== function showLoserAnimation(resultIndex) { let frame = 0; const maxFrames = 100; const x = (resultIndex + 1) * verticalGap; const y = endY + 20; let animationId = null; function animate() { if (frame >= maxFrames) { cancelAnimationFrame(animationId); return; } // 캔버스 그대로 유지하면서 애니메이션 요소만 다시 그리기 clearCanvas(); drawLadderOnCanvas(); drawParticipantsAndLosers(); // 꽝 박스 강조 표시 애니메이션 const blinkState = Math.sin(frame * 0.3); const scale = 1 + Math.abs(blinkState) * 0.2; const rotation = (blinkState * 5) * (Math.PI / 180); // 최대 ±5도 회전 // 박스 배경 (크기 조절 및 회전) ctx.save(); ctx.translate(x, y); ctx.rotate(rotation); ctx.scale(scale, scale); // 꽝 박스 ctx.fillStyle = blinkState > 0 ? '#ffcccc' : '#ffeeee'; ctx.beginPath(); ctx.roundRect(-40, -15, 80, 30, 5); ctx.fill(); // 꽝 텍스트 ctx.fillStyle = '#d92550'; ctx.font = 'bold 18px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText("꽝!!!", 0, 0); ctx.restore(); // 주변 이펙트 (방사형 파동) if (frame > 10) { const waveRadius = (frame - 10) * 3; const alpha = 1 - (frame - 10) / maxFrames; ctx.strokeStyle = `rgba(217, 37, 80, ${alpha})`; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, waveRadius, 0, Math.PI * 2); ctx.stroke(); // 추가 파동 (약간 딜레이) if (frame > 20) { const waveRadius2 = (frame - 20) * 3; const alpha2 = 1 - (frame - 20) / maxFrames; ctx.strokeStyle = `rgba(217, 37, 80, ${alpha2})`; ctx.beginPath(); ctx.arc(x, y, waveRadius2, 0, Math.PI * 2); ctx.stroke(); } } // 음표 이펙트 추가 (타이밍별로 다르게) if (frame > 15 && frame < 60) { drawMusicNote(x - 60 + (frame % 5) * 10, y - 30 - frame % 15, frame); drawMusicNote(x + 30 + (frame % 7) * 5, y - 20 - frame % 10, frame + 10); } frame++; animationId = requestAnimationFrame(animate); } // 음표 그리기 함수 function drawMusicNote(noteX, noteY, frameOffset) { const noteAlpha = 1 - (frameOffset % 30) / 30; ctx.fillStyle = `rgba(217, 37, 80, ${noteAlpha})`; // 음표 머리 ctx.beginPath(); ctx.ellipse(noteX, noteY, 6, 4, Math.PI/4, 0, Math.PI * 2); ctx.fill(); // 음표 꼬리 ctx.beginPath(); ctx.moveTo(noteX + 4, noteY - 3); ctx.lineTo(noteX + 4, noteY - 20); ctx.stroke(); } // 애니메이션 시작 animate(); } // =================================== // 게임 리셋 // =================================== function resetGame() { // 애니메이션 취소 if (animationId) { cancelAnimationFrame(animationId); } if (selectionAnimationId) { cancelAnimationFrame(selectionAnimationId); selectionAnimationId = null; } // 안내 타이머 제거 if (instructionTimer) { clearTimeout(instructionTimer); instructionTimer = null; } // 완료 참가자 목록 초기화 completedParticipants = []; // 결과 참가자 목록 초기화 resultParticipants = []; // 버튼 상태 변경 $('#draw-ladder').show(); $('#start-game').hide().prop('disabled', false); $('#reset-game').hide(); // 캔버스 초기화 clearCanvas(); } // 초기화 함수 호출 init(); });