765 lines
24 KiB
HTML
765 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>VR Charades Experiment Control</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', sans-serif;
|
|
background: #f5f5f5;
|
|
color: #2c3e50;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.header {
|
|
background: #2c3e50;
|
|
color: white;
|
|
padding: 1.5rem 2rem;
|
|
border-bottom: 3px solid #34495e;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.header p {
|
|
font-size: 0.9rem;
|
|
color: #bdc3c7;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.timer-section {
|
|
background: white;
|
|
border: 2px solid #34495e;
|
|
border-radius: 4px;
|
|
padding: 2rem;
|
|
margin-bottom: 2rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.timer-label {
|
|
font-size: 0.875rem;
|
|
color: #7f8c8d;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.timer-display {
|
|
font-family: 'Roboto Mono', monospace;
|
|
font-size: 4rem;
|
|
font-weight: 500;
|
|
color: #2c3e50;
|
|
}
|
|
|
|
.timer-display.warning {
|
|
color: #e67e22;
|
|
}
|
|
|
|
.timer-display.danger {
|
|
color: #e74c3c;
|
|
}
|
|
|
|
.timer-display.inactive {
|
|
color: #bdc3c7;
|
|
}
|
|
|
|
.main-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 2rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.section {
|
|
background: white;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: #2c3e50;
|
|
margin-bottom: 1.5rem;
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 2px solid #ecf0f1;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.form-label {
|
|
display: block;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: #34495e;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.form-input {
|
|
width: 100%;
|
|
padding: 0.625rem 0.75rem;
|
|
border: 1px solid #cbd5e0;
|
|
border-radius: 3px;
|
|
font-size: 0.9375rem;
|
|
font-family: inherit;
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
.form-input:focus {
|
|
outline: none;
|
|
border-color: #3498db;
|
|
}
|
|
|
|
.form-input:disabled {
|
|
background: #f8f9fa;
|
|
color: #adb5bd;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.radio-group {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.radio-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.radio-label input {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0.625rem 1.25rem;
|
|
border: none;
|
|
border-radius: 3px;
|
|
font-size: 0.9375rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #3498db;
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
background: #2980b9;
|
|
}
|
|
|
|
.btn-success {
|
|
background: #27ae60;
|
|
color: white;
|
|
}
|
|
|
|
.btn-success:hover:not(:disabled) {
|
|
background: #229954;
|
|
}
|
|
|
|
.btn-danger {
|
|
background: #e74c3c;
|
|
color: white;
|
|
}
|
|
|
|
.btn-danger:hover:not(:disabled) {
|
|
background: #c0392b;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #95a5a6;
|
|
color: white;
|
|
}
|
|
|
|
.btn-secondary:hover:not(:disabled) {
|
|
background: #7f8c8d;
|
|
}
|
|
|
|
.btn-group {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.controls-section {
|
|
background: white;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
padding: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.controls-section .btn-group {
|
|
margin-top: 0;
|
|
}
|
|
|
|
textarea {
|
|
font-family: 'Roboto Mono', monospace;
|
|
font-size: 0.875rem;
|
|
line-height: 1.5;
|
|
resize: vertical;
|
|
}
|
|
|
|
.word-display {
|
|
background: white;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.word-list-container {
|
|
max-height: 500px;
|
|
overflow-y: auto;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.word-item {
|
|
background: #fafafa;
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 3px;
|
|
padding: 0.75rem 1rem;
|
|
margin-bottom: 0.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.word-item:hover {
|
|
background: #f0f0f0;
|
|
}
|
|
|
|
.word-item.active {
|
|
background: #e3f2fd;
|
|
border-color: #3498db;
|
|
}
|
|
|
|
.word-item.correct {
|
|
background: #e8f5e9;
|
|
border-color: #27ae60;
|
|
}
|
|
|
|
.word-item.incorrect {
|
|
background: #ffebee;
|
|
border-color: #e74c3c;
|
|
}
|
|
|
|
.word-content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex: 1;
|
|
}
|
|
|
|
.word-index {
|
|
font-family: 'Roboto Mono', monospace;
|
|
font-size: 0.8125rem;
|
|
color: #7f8c8d;
|
|
min-width: 2.5rem;
|
|
}
|
|
|
|
.word-text {
|
|
font-size: 0.9375rem;
|
|
color: #2c3e50;
|
|
}
|
|
|
|
.word-timer {
|
|
font-family: 'Roboto Mono', monospace;
|
|
font-size: 1rem;
|
|
color: #34495e;
|
|
min-width: 4rem;
|
|
text-align: right;
|
|
}
|
|
|
|
.word-checkbox {
|
|
margin-left: 1rem;
|
|
}
|
|
|
|
.placeholder {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
color: #95a5a6;
|
|
}
|
|
|
|
.small-text {
|
|
font-size: 0.8125rem;
|
|
color: #7f8c8d;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.main-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.form-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.timer-display {
|
|
font-size: 3rem;
|
|
}
|
|
|
|
.btn-group {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.btn {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>VR Charades Experiment Control</h1>
|
|
<p>Experiment management interface</p>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<!-- Timer Display -->
|
|
<div class="timer-section">
|
|
<div class="timer-label">Total Time Remaining</div>
|
|
<div id="timer-display" class="timer-display inactive">--:--</div>
|
|
</div>
|
|
|
|
<!-- Main Configuration -->
|
|
<div class="main-grid">
|
|
<div class="section">
|
|
<h2 class="section-title">Session Configuration</h2>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Group ID</label>
|
|
<input type="text" id="group-id" class="form-input" placeholder="e.g., g1, team-alpha">
|
|
<div class="small-text">Used in exported CSV filename</div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label class="form-label">Time per Word (seconds)</label>
|
|
<input type="number" id="time-s" class="form-input" value="30" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Total Duration (minutes)</label>
|
|
<input type="number" id="total-duration" class="form-input" value="0" min="0" step="0.5">
|
|
<div class="small-text">0 = unlimited</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2 class="section-title">Network Configuration</h2>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Player 1 IP Address</label>
|
|
<input type="text" id="ip-player1" class="form-input" placeholder="10.42.0.38">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Player 2 IP Address</label>
|
|
<input type="text" id="ip-player2" class="form-input" placeholder="10.42.0.100">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Active Player</label>
|
|
<div class="radio-group">
|
|
<label class="radio-label">
|
|
<input type="radio" name="active-player" value="player1" checked>
|
|
<span>Player 1</span>
|
|
</label>
|
|
<label class="radio-label">
|
|
<input type="radio" name="active-player" value="player2">
|
|
<span>Player 2</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Word List -->
|
|
<div class="section">
|
|
<h2 class="section-title">Word List</h2>
|
|
|
|
<div class="form-group">
|
|
<textarea id="word-list" class="form-input" rows="12" placeholder="Enter one word per line..."></textarea>
|
|
</div>
|
|
|
|
<div class="btn-group">
|
|
<button id="btn-shuffle" class="btn btn-secondary">Shuffle Words</button>
|
|
<button id="btn-start" class="btn btn-success">Start Experiment</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
<div class="controls-section">
|
|
<h2 class="section-title">Experiment Controls</h2>
|
|
<div class="btn-group">
|
|
<button id="btn-stop" class="btn btn-danger" disabled>Stop Experiment</button>
|
|
<button id="btn-save" class="btn btn-primary" disabled>Save Results (CSV)</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress Display -->
|
|
<div class="word-display">
|
|
<h2 class="section-title">Experiment Progress</h2>
|
|
<div id="word-list-display" class="word-list-container">
|
|
<div class="placeholder">
|
|
<p><strong>No active experiment</strong></p>
|
|
<p>Configure settings above and click "Start Experiment" to begin</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let runningWordIndex = -1;
|
|
let runningWordList = [];
|
|
let experimentStartTime = null;
|
|
let totalDurationMs = 0;
|
|
let frameId = undefined;
|
|
let last = undefined;
|
|
let newWord;
|
|
let lastWordStatus = -1;
|
|
let experimentRunning = false;
|
|
|
|
function updateButtonStates() {
|
|
document.getElementById("btn-stop").disabled = !experimentRunning;
|
|
document.getElementById("btn-save").disabled = experimentRunning || runningWordList.length === 0;
|
|
document.getElementById("btn-start").disabled = experimentRunning;
|
|
document.getElementById("btn-shuffle").disabled = experimentRunning;
|
|
}
|
|
|
|
function createWordItems() {
|
|
const wordList = document.getElementById("word-list");
|
|
const text = wordList.value;
|
|
const display = document.getElementById("word-list-display");
|
|
display.innerHTML = "";
|
|
runningWordList = [];
|
|
runningWordIndex = -1;
|
|
|
|
const words = text.trim().split('\n').filter(w => w.trim());
|
|
|
|
words.forEach((word, index) => {
|
|
const div = document.createElement("div");
|
|
div.classList.add("word-item");
|
|
div.setAttribute("data-word", word.trim());
|
|
div.setAttribute("data-index", index);
|
|
|
|
const content = document.createElement("div");
|
|
content.classList.add("word-content");
|
|
|
|
const indexSpan = document.createElement("span");
|
|
indexSpan.classList.add("word-index");
|
|
indexSpan.textContent = `${(index + 1).toString().padStart(2, '0')}.`;
|
|
|
|
const textSpan = document.createElement("span");
|
|
textSpan.classList.add("word-text");
|
|
textSpan.textContent = word.trim();
|
|
|
|
content.appendChild(indexSpan);
|
|
content.appendChild(textSpan);
|
|
|
|
const timer = document.createElement("div");
|
|
timer.classList.add("word-timer");
|
|
const timeSeconds = parseFloat(document.getElementById("time-s").value);
|
|
timer.textContent = timeSeconds.toFixed(1) + "s";
|
|
timer.setAttribute("data-remaining", timeSeconds);
|
|
|
|
const checkbox = document.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.classList.add("word-checkbox");
|
|
checkbox.addEventListener("change", () => {
|
|
if (checkbox.checked) {
|
|
div.classList.add("correct");
|
|
div.classList.remove("incorrect");
|
|
} else {
|
|
div.classList.remove("correct");
|
|
}
|
|
});
|
|
|
|
div.appendChild(content);
|
|
div.appendChild(timer);
|
|
div.appendChild(checkbox);
|
|
display.appendChild(div);
|
|
|
|
runningWordList.push(div);
|
|
});
|
|
}
|
|
|
|
function step(timestamp) {
|
|
if (last === undefined) {
|
|
last = timestamp;
|
|
experimentStartTime = timestamp;
|
|
runningWordIndex = 0;
|
|
newWord = true;
|
|
lastWordStatus = -1;
|
|
}
|
|
|
|
const elapsed = timestamp - last;
|
|
last = timestamp;
|
|
|
|
// Update total timer
|
|
const timerDisplay = document.getElementById("timer-display");
|
|
if (totalDurationMs > 0) {
|
|
const totalElapsed = timestamp - experimentStartTime;
|
|
const remainingMs = totalDurationMs - totalElapsed;
|
|
|
|
const remainingMinutes = Math.floor(remainingMs / 60000);
|
|
const remainingSeconds = Math.floor((remainingMs % 60000) / 1000);
|
|
timerDisplay.textContent =
|
|
`${remainingMinutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
|
|
timerDisplay.className = "timer-display";
|
|
if (remainingMs < 60000) {
|
|
timerDisplay.classList.add("danger");
|
|
} else if (remainingMs < 120000) {
|
|
timerDisplay.classList.add("warning");
|
|
}
|
|
|
|
if (totalElapsed >= totalDurationMs) {
|
|
stopExperiment();
|
|
alert("Experiment completed: Total duration reached");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (runningWordIndex >= runningWordList.length) {
|
|
stopExperiment();
|
|
return;
|
|
}
|
|
|
|
const currentWordDiv = runningWordList[runningWordIndex];
|
|
const timer = currentWordDiv.querySelector(".word-timer");
|
|
const checkbox = currentWordDiv.querySelector('input[type="checkbox"]');
|
|
|
|
let remainingTime = parseFloat(timer.getAttribute("data-remaining")) - (elapsed / 1000);
|
|
|
|
if (newWord) {
|
|
newWord = false;
|
|
const word = currentWordDiv.getAttribute("data-word");
|
|
sendNewWord(word, lastWordStatus, remainingTime);
|
|
|
|
// Remove active class from all
|
|
runningWordList.forEach(div => div.classList.remove("active"));
|
|
currentWordDiv.classList.add("active");
|
|
}
|
|
|
|
if (checkbox.checked || remainingTime < 0) {
|
|
if (remainingTime < 0) remainingTime = 0;
|
|
|
|
lastWordStatus = checkbox.checked ? 1 : 0;
|
|
timer.textContent = remainingTime.toFixed(1) + "s";
|
|
currentWordDiv.classList.remove("active");
|
|
|
|
if (checkbox.checked) {
|
|
currentWordDiv.classList.add("correct");
|
|
} else {
|
|
currentWordDiv.classList.add("incorrect");
|
|
}
|
|
|
|
runningWordIndex++;
|
|
newWord = true;
|
|
} else {
|
|
timer.textContent = remainingTime.toFixed(1) + "s";
|
|
timer.setAttribute("data-remaining", remainingTime);
|
|
}
|
|
|
|
if (runningWordIndex < runningWordList.length) {
|
|
frameId = requestAnimationFrame(step);
|
|
} else {
|
|
stopExperiment();
|
|
}
|
|
}
|
|
|
|
function stopExperiment() {
|
|
if (frameId) {
|
|
cancelAnimationFrame(frameId);
|
|
frameId = undefined;
|
|
}
|
|
experimentRunning = false;
|
|
updateButtonStates();
|
|
console.log("Experiment stopped");
|
|
}
|
|
|
|
function sendNewWord(word, lastWordStatus, timeSeconds) {
|
|
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
|
|
const ipPlayer1 = document.getElementById("ip-player1").value;
|
|
const ipPlayer2 = document.getElementById("ip-player2").value;
|
|
|
|
const target = isPlayer1 ? ipPlayer1 : ipPlayer2;
|
|
const targetOther = isPlayer1 ? ipPlayer2 : ipPlayer1;
|
|
|
|
fetch("/word", {
|
|
method: "POST",
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
target: target,
|
|
lastWordStatus: lastWordStatus,
|
|
timeSeconds: timeSeconds,
|
|
word: word,
|
|
}),
|
|
});
|
|
|
|
fetch("/word", {
|
|
method: "POST",
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
target: targetOther,
|
|
lastWordStatus: lastWordStatus,
|
|
timeSeconds: 0.0,
|
|
word: "",
|
|
}),
|
|
});
|
|
}
|
|
|
|
// Event Listeners
|
|
document.getElementById("btn-start").addEventListener("click", () => {
|
|
const groupId = document.getElementById("group-id").value;
|
|
|
|
if (!groupId || !groupId.trim()) {
|
|
alert("Please enter a Group ID before starting.");
|
|
return;
|
|
}
|
|
|
|
const wordList = document.getElementById("word-list").value;
|
|
if (!wordList.trim()) {
|
|
alert("Please enter a word list before starting.");
|
|
return;
|
|
}
|
|
|
|
const totalDurationMin = parseFloat(document.getElementById("total-duration").value) || 0;
|
|
totalDurationMs = totalDurationMin * 60 * 1000;
|
|
|
|
const timerDisplay = document.getElementById("timer-display");
|
|
if (totalDurationMs > 0) {
|
|
const minutes = Math.floor(totalDurationMs / 60000);
|
|
const seconds = Math.floor((totalDurationMs % 60000) / 1000);
|
|
timerDisplay.textContent =
|
|
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
timerDisplay.className = "timer-display";
|
|
} else {
|
|
timerDisplay.textContent = "∞";
|
|
timerDisplay.className = "timer-display";
|
|
}
|
|
|
|
createWordItems();
|
|
if (frameId) cancelAnimationFrame(frameId);
|
|
|
|
last = undefined;
|
|
newWord = undefined;
|
|
experimentStartTime = null;
|
|
experimentRunning = true;
|
|
updateButtonStates();
|
|
|
|
frameId = requestAnimationFrame(step);
|
|
|
|
console.log(`Experiment started: Group=${groupId}, Duration=${totalDurationMin}min`);
|
|
});
|
|
|
|
document.getElementById("btn-stop").addEventListener("click", stopExperiment);
|
|
|
|
document.getElementById("btn-save").addEventListener("click", () => {
|
|
if (runningWordList.length === 0) {
|
|
alert("No data to save. Please run the experiment first.");
|
|
return;
|
|
}
|
|
|
|
const groupId = document.getElementById("group-id").value || "group";
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
|
|
|
let data = "word;correct;time_left\n";
|
|
const maxIndex = runningWordIndex >= runningWordList.length ? runningWordList.length : runningWordIndex;
|
|
|
|
for (let i = 0; i < maxIndex; i++) {
|
|
const div = runningWordList[i];
|
|
const word = div.getAttribute("data-word");
|
|
const checkbox = div.querySelector('input[type="checkbox"]');
|
|
const timer = div.querySelector(".word-timer");
|
|
const isCorrect = checkbox.checked;
|
|
const timeLeft = parseFloat(timer.getAttribute("data-remaining"));
|
|
|
|
data += `${word};${isCorrect};${timeLeft}\n`;
|
|
}
|
|
|
|
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement("a");
|
|
const url = URL.createObjectURL(blob);
|
|
link.href = url;
|
|
link.download = `${groupId}_results_${timestamp}.csv`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
console.log(`Saved ${maxIndex} out of ${runningWordList.length} words to CSV`);
|
|
});
|
|
|
|
document.getElementById("btn-shuffle").addEventListener("click", async () => {
|
|
const wordList = document.getElementById("word-list");
|
|
const text = wordList.value.trim();
|
|
|
|
if (!text) {
|
|
alert("Please enter words to shuffle first.");
|
|
return;
|
|
}
|
|
|
|
const words = text.split('\n').map(word => word.trim()).filter(word => word);
|
|
|
|
try {
|
|
const response = await fetch("/shuffle", {
|
|
method: "POST",
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ words: words }),
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.status === "ok") {
|
|
wordList.value = result.shuffled_words.join('\n');
|
|
}
|
|
} catch (error) {
|
|
console.error("Error shuffling words:", error);
|
|
alert("Failed to shuffle words. Please try again.");
|
|
}
|
|
});
|
|
|
|
// Initialize button states
|
|
updateButtonStates();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|