|
|
현재 볼링장 행사에 사용중인 레인추첨 프로그램입니다.
계속 사용하면서 지속적으로 업데이트 중이에요!
클럽 정기전 행사할 때 사용하셔도 좋을 거 같아 공유합니다
사용해보시고 개선해야 할 문제가 있으면 알려주세요~
ver.pro+ 개선사항
📌 사전 섞기과정 추가
📌 함수 로직 > 보안 랜덤 로직으로 수정
📌 이전 결과 기억으로 동일한 레인에 배정되는 경우 최소화
아래는 현재 레인추첨기 스크립트입니다
고수님들 참견은 언제나 환영입니다 :)
노과장에게 알려주세요!
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>동화볼링센터 운명의 레인추첨 (Pro+)</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<style>
body { font-family: 'Malgun Gothic', sans-serif; text-align: center; padding: 40px 20px; background-color: #f0f2f5; margin: 0; }
.container { max-width: 900px; margin: 0 auto; background: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.settings-group { background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; margin-bottom: 20px; text-align: left; }
.input-row { display: flex; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 10px; }
.input-row label { font-weight: bold; color: #495057; min-width: 120px; }
.input-row input[type="text"] { flex: 1; padding: 10px; font-size: 16px; border: 1px solid #ccc; border-radius: 5px; }
.input-row input[type="number"] { width: 80px; padding: 10px; font-size: 16px; border: 1px solid #ccc; border-radius: 5px; text-align: center; }
textarea { width: 100%; padding: 15px; font-size: 16px; border: 1px solid #ccc; border-radius: 8px; box-sizing: border-box; resize: vertical; margin-bottom: 20px; }
.btn-draw { background-color: #0056b3; color: white; border: none; padding: 15px 40px; font-size: 18px; font-weight: bold; border-radius: 8px; cursor: pointer; transition: background-color 0.3s; width: 100%; }
.btn-draw:hover { background-color: #004494; }
.btn-draw:disabled { background-color: #999; cursor: not-allowed; }
.btn-group { display: none; margin-top: 20px; gap: 10px; justify-content: center; }
.btn-save { background-color: #28a745; color: white; border: none; padding: 15px 30px; font-size: 17px; font-weight: bold; border-radius: 8px; cursor: pointer; }
.btn-reset { background-color: #6c757d; color: white; border: none; padding: 15px 30px; font-size: 17px; font-weight: bold; border-radius: 8px; cursor: pointer; }
#animContainer { display: none; padding: 40px 20px; background-color: #2c3e50; border-radius: 12px; color: white; margin-top: 20px; min-height: 200px; }
.anim-title { font-size: 24px; font-weight: bold; margin-bottom: 20px; color: #f1c40f; }
.anim-list-wrap { display: flex; justify-content: center; gap: 40px; overflow: hidden; height: 150px; }
.anim-column { display: flex; flex-direction: column; gap: 8px; font-size: 18px; font-weight: bold; transition: all 0.5s ease-in-out; }
.slot-machine { font-size: 20px; column-count: 3; text-align: left; opacity: 0.7; }
#captureArea { margin-top: 30px; padding: 25px; background-color: #ffffff; border-radius: 12px; display: none; border: 1px solid #eee; }
#resultArea { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 15px; text-align: left; }
.lane-card { background-color: #fff; border: 2px solid #0056b3; border-radius: 8px; padding: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
.lane-title { font-weight: bold; font-size: 18px; color: white; background-color: #0056b3; margin: -15px -15px 10px -15px; padding: 8px; border-radius: 5px 5px 0 0; text-align: center; }
.player-item { font-size: 16px; margin-bottom: 6px; line-height: 1.4; border-bottom: 1px dotted #eee; }
.player-item:last-child { border-bottom: none; }
.result-header { font-size: 28px; font-weight: bold; color: #333; margin-bottom: 5px; display: none; }
.result-sub { font-size: 14px; color: #e74c3c; margin-bottom: 20px; font-weight: bold; display: none; }
</style>
</head>
<body>
<div class="container">
<h2 id="mainTitle">🎳 동화볼링센터 운명의 레인추첨 (Pro+) 🎳</h2>
<div class="settings-group" id="settingsArea">
<div class="input-row">
<label>🏆 대회명</label>
<input type="text" id="matchTitle" placeholder="예: 2월 정기 교류전">
</div>
<div class="number-inputs">
<div class="input-row">
<label>시작 레인</label>
<input type="number" id="startLane" value="1" min="1">
</div>
<div class="input-row">
<label>끝 레인</label>
<input type="number" id="endLane" value="12" min="1">
</div>
</div>
<p style="font-size: 13px; color: #666; margin-top: 5px;">
* <b>AI 스마트 배정:</b> 참가자가 직전 경기와 <b>동일한 레인(위치)에 연속으로 배정되는 것을 최대한 방지</b>합니다.
</p>
</div>
<textarea id="inputText" rows="10" placeholder="참가자 명단을 한 줄에 한 명씩 입력하세요."></textarea>
<button id="drawButton" class="btn-draw" xxxxonclick="startProcess()">운명의 레인 추첨 시작! 🔥</button>
<div id="animContainer">
<div class="anim-title" id="animTitle">명단 준비 중...</div>
<div class="anim-list-wrap" id="animListWrap"></div>
</div>
<div id="captureArea">
<div class="result-header" id="resultHeader">🎉 레인 배정 결과 🎉</div>
<div class="result-sub" id="resultSub"></div>
<div id="resultArea"></div>
</div>
<div class="btn-group" id="btnGroup">
<button class="btn-save" xxxxonclick="saveAsImage()">📸 사진으로 저장</button>
<button class="btn-reset" xxxxonclick="location.reload()">🔄 다시 설정하기</button>
</div>
</div>
<script>
function cryptoShuffle(array) {
let arr = [...array];
let currentIndex = arr.length, randomIndex;
const randomBuffer = new Uint32Array(1);
while (currentIndex !== 0) {
window.crypto.getRandomValues(randomBuffer);
randomIndex = randomBuffer[0] % currentIndex;
currentIndex--;
[arr[currentIndex], arr[randomIndex]] = [arr[randomIndex], arr[currentIndex]];
}
return arr;
}
function calculateGroups(array, totalLanes) {
const totalPairs = Math.ceil(totalLanes / 2);
const totalPlayers = array.length;
const basePlayersPerPair = Math.floor(totalPlayers / totalPairs);
let extraPlayersForPair = totalPlayers % totalPairs;
let groups = [];
let playerIdx = 0;
for (let i = 0; i < totalPairs; i++) {
const playersInThisPair = basePlayersPerPair + (extraPlayersForPair > 0 ? 1 : 0);
extraPlayersForPair--;
let currentGroup = [];
for (let j = 0; j < playersInThisPair; j++) {
if (playerIdx < totalPlayers) {
currentGroup.push(array[playerIdx]);
playerIdx++;
}
}
groups.push(currentGroup);
}
return groups;
}
function startProcess() {
const inputText = document.getElementById("inputText").value;
let items = inputText.split('\n').map(item => item.trim()).filter(item => item !== "");
if (items.length === 0) return alert("참가자 이름을 입력해 주세요!");
const startLane = parseInt(document.getElementById("startLane").value) || 1;
const endLane = parseInt(document.getElementById("endLane").value) || 12;
const totalLanes = endLane - startLane + 1;
if (startLane > endLane) return alert("시작 레인이 끝 레인보다 클 수 없습니다.");
document.getElementById("settingsArea").style.display = "none";
document.getElementById("inputText").style.display = "none";
document.getElementById("drawButton").style.display = "none";
const animContainer = document.getElementById("animContainer");
const animTitle = document.getElementById("animTitle");
const animListWrap = document.getElementById("animListWrap");
animContainer.style.display = "block";
animTitle.innerText = "1단계: 사전 컷팅 (명단 반갈라치기)";
let half = Math.ceil(items.length / 2);
let leftGroup = items.slice(0, half);
let rightGroup = items.slice(half);
animListWrap.innerHTML = `
<div class="anim-column" id="leftCol">${leftGroup.slice(0,5).map(n=>`<div>${n}</div>`).join('')}...</div>
<div class="anim-column" id="rightCol">${rightGroup.slice(0,5).map(n=>`<div>${n}</div>`).join('')}...</div>
`;
setTimeout(() => {
animTitle.innerText = "명단 교차 병합 중...";
document.getElementById("leftCol").style.transform = "translateX(50px)";
document.getElementById("rightCol").style.transform = "translateX(-50px)";
document.getElementById("leftCol").style.opacity = "0.5";
document.getElementById("rightCol").style.opacity = "0.5";
}, 1500);
setTimeout(() => {
animTitle.innerText = "2단계: 연속 동일 레인 배정 방지 연산 중 ⚡";
let slotInterval = setInterval(() => {
let randomPreview = cryptoShuffle(items).slice(0, 9);
animListWrap.innerHTML = `<div class="slot-machine">${randomPreview.map(n=>`<div>${n}</div>`).join('')}</div>`;
}, 100);
// --- 과거 레인 위치 데이터 불러오기 ---
let pastPositionsStr = localStorage.getItem('bowling_last_positions');
let pastPositions = pastPositionsStr ? JSON.parse(pastPositionsStr) : {};
let bestArray = [];
let minPenalty = Infinity;
// --- 500번 시뮬레이션 (동일 레인 배정 시 페널티) ---
for(let tryCount = 0; tryCount < 500; tryCount++) {
let tempArray = cryptoShuffle(items);
let testGroups = calculateGroups(tempArray, totalLanes);
let currentPenalty = 0;
testGroups.forEach((group, pairIdx) => {
group.forEach((player) => {
// [벌점 +1] 이전 경기와 동일한 조(Pair) 위치인 경우에만 벌점
if (pastPositions[player] === pairIdx) {
currentPenalty += 1;
}
});
});
if(currentPenalty < minPenalty) {
minPenalty = currentPenalty;
bestArray = tempArray;
}
if(minPenalty === 0) break; // 벌점 0점(완벽히 다른 레인)이면 즉시 종료
}
// --- 이번 결과의 '레인 위치'만 과거 기록으로 저장 ---
let finalGroups = calculateGroups(bestArray, totalLanes);
let finalPositions = {};
finalGroups.forEach((group, pairIdx) => {
group.forEach(player => {
finalPositions[player] = pairIdx;
});
});
localStorage.setItem('bowling_last_positions', JSON.stringify(finalPositions));
setTimeout(() => {
clearInterval(slotInterval);
animContainer.style.display = "none";
renderFinalResults(bestArray, startLane, endLane, minPenalty);
}, 2000);
}, 3000);
}
function renderFinalResults(array, startLane, endLane, penaltyScore) {
document.getElementById("captureArea").style.display = "block";
document.getElementById("resultHeader").style.display = "block";
document.getElementById("btnGroup").style.display = "flex";
const matchTitleInput = document.getElementById("matchTitle").value.trim();
document.getElementById("resultHeader").innerText = matchTitleInput ? `🎉 ${matchTitleInput} 🎉` : "🎉 레인 배정 결과 🎉";
// 벌점이 남아있다면 안내 문구 출력
if(localStorage.getItem('bowling_last_positions') && penaltyScore > 0) {
let subText = document.getElementById("resultSub");
subText.style.display = "block";
subText.innerText = "💡 인원 구조상 불가피하게 일부 인원은 직전과 동일한 레인에 배정되었습니다. (최적의 타협점 도출)";
}
const totalLanes = endLane - startLane + 1;
const groups = calculateGroups(array, totalLanes);
let resultHTML = "";
let groupIdx = 0;
for (let i = 0; i < Math.ceil(totalLanes / 2); i++) {
const lane1_Num = startLane + (i * 2);
const lane2_Num = lane1_Num + 1;
const currentGroupPlayers = groups[groupIdx] || [];
groupIdx++;
const lane1_Count = Math.floor(currentGroupPlayers.length / 2);
const lane2_Count = Math.ceil(currentGroupPlayers.length / 2);
if (lane1_Num <= endLane) {
resultHTML += createLaneCard(lane1_Num, currentGroupPlayers.slice(0, lane1_Count));
}
if (lane2_Num <= endLane) {
resultHTML += createLaneCard(lane2_Num, currentGroupPlayers.slice(lane1_Count, lane1_Count + lane2_Count));
}
}
document.getElementById("resultArea").innerHTML = resultHTML;
}
function createLaneCard(laneNum, players) {
let card = `<div class="lane-card"><div class="lane-title">${laneNum} 레인</div>`;
if (players.length === 0) {
card += `<div class="player-item" style="color:#ccc;">(비어 있음)</div>`;
} else {
players.forEach((name, index) => { card += `<div class="player-item"><b>${index + 1}.</b> ${name}</div>`; });
}
card += `</div>`;
return card;
}
function saveAsImage() {
html2canvas(document.getElementById("captureArea"), { scale: 2, backgroundColor: "#ffffff" }).then(canvas => {
const link = document.createElement('a');
link.download = (document.getElementById("matchTitle").value.trim() || '레인배정') + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
});
}
</script>
</body>
</html>
|
|
