/*
* 파일명: 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();
});