Files
wordsearch/static/js/game.js
T
Mathew Sir Guest the bestandClaude Opus 4.6 36c3c712fe feat: add scoreboard with player names, per-category leaderboards, and themed tables
Add player_name column to games (migration included), with regex-based
validation and anti-abuse sanitization. New /scoreboard page shows recent
games from localStorage, popular categories with play counts, and top-10
per-category leaderboards sorted by completion time. Also includes
two-click reverse word matching, base URL prefix support for reverse-proxy
hosting, BasePageHandler refactoring, themed table CSS for all 5 themes,
and comprehensive test coverage for player names and scoreboard API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 10:38:19 -06:00

346 lines
12 KiB
JavaScript

(function () {
"use strict";
var BASE = document.body.dataset.baseUrl || "/";
let gameState = null;
let timerInterval = null;
let selectedCells = [];
let feedbackTimeout = null;
function titleCase(word) {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
// DOM refs
const setupDiv = document.getElementById("game-setup");
const boardDiv = document.getElementById("game-board");
const categorySelect = document.getElementById("category-select");
const boardSizeInput = document.getElementById("board-size");
const sizeDisplay = document.getElementById("size-display");
const wordCountInput = document.getElementById("word-count");
const wordCountDisplay = document.getElementById("word-count-display");
const newGameBtn = document.getElementById("new-game-btn");
const gridTable = document.getElementById("grid-table");
const wordList = document.getElementById("word-list");
const wordInput = document.getElementById("word-input");
const guessBtn = document.getElementById("guess-btn");
const clearSelectionBtn = document.getElementById("clear-selection-btn");
const guessFeedback = document.getElementById("guess-feedback");
const timerEl = document.getElementById("timer");
const completionBanner = document.getElementById("completion-banner");
const finalTime = document.getElementById("final-time");
const playAgainBtn = document.getElementById("play-again-btn");
const inputArea = document.getElementById("input-area");
const playerNameInput = document.getElementById("player-name");
// Load categories
async function loadCategories() {
try {
const resp = await fetch(BASE + "api/categories");
if (!resp.ok) throw new Error("Server error");
const categories = await resp.json();
categorySelect.innerHTML = "";
categories.forEach(function (cat) {
const opt = document.createElement("option");
opt.value = cat.id;
opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1);
categorySelect.appendChild(opt);
});
} catch (err) {
showFeedback("Failed to load categories.", "text-danger");
}
}
boardSizeInput.addEventListener("input", function () {
sizeDisplay.textContent = this.value;
});
wordCountInput.addEventListener("input", function () {
wordCountDisplay.textContent = this.value;
});
// New game
newGameBtn.addEventListener("click", async function () {
try {
const categoryId = parseInt(categorySelect.value);
const boardSize = parseInt(boardSizeInput.value);
const wordCount = parseInt(wordCountInput.value);
const playerName = playerNameInput.value.trim();
const resp = await fetch(BASE + "api/game/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category_id: categoryId,
board_size: boardSize,
word_count: wordCount,
player_name: playerName,
}),
});
if (!resp.ok) {
const errData = await resp.json().catch(function () { return {}; });
throw new Error(errData.error || "Failed to create game");
}
gameState = await resp.json();
showBoard();
} catch (err) {
showFeedback(err.message || "Failed to create game.", "text-danger");
}
});
function showBoard() {
setupDiv.classList.add("d-none");
boardDiv.classList.remove("d-none");
completionBanner.classList.add("d-none");
playAgainBtn.classList.add("d-none");
inputArea.classList.remove("d-none");
selectedCells = [];
renderGrid();
renderWordList();
startTimer();
}
function renderGrid() {
gridTable.innerHTML = "";
gameState.grid.forEach(function (row, r) {
const tr = document.createElement("tr");
row.forEach(function (letter, c) {
const td = document.createElement("td");
td.textContent = letter;
td.dataset.row = r;
td.dataset.col = c;
td.addEventListener("click", function () {
onCellClick(r, c, td);
});
tr.appendChild(td);
});
gridTable.appendChild(tr);
});
}
function renderWordList() {
wordList.innerHTML = "";
gameState.words.forEach(function (w) {
const li = document.createElement("li");
li.className = "list-group-item";
li.textContent = titleCase(w.text);
li.dataset.word = w.text;
if (w.found) {
li.classList.add("word-found");
}
wordList.appendChild(li);
});
}
// Timer
function startTimer() {
if (timerInterval) clearInterval(timerInterval);
const startedAt = new Date(gameState.started_at);
function tick() {
const elapsed = Math.floor((Date.now() - startedAt.getTime()) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
timerEl.textContent = mins + ":" + (secs < 10 ? "0" : "") + secs;
}
tick();
timerInterval = setInterval(tick, 1000);
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
// Text guess
guessBtn.addEventListener("click", submitTextGuess);
wordInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") submitTextGuess();
});
async function submitTextGuess() {
const word = wordInput.value.trim();
if (!word) return;
wordInput.value = "";
guessBtn.disabled = true;
try {
await sendGuess({ word: word });
} finally {
guessBtn.disabled = false;
}
}
// Click selection — two-click: first letter, then last letter
function onCellClick(row, col, td) {
if (gameState.status === "completed") return;
// No start cell yet — this is the first click
if (selectedCells.length === 0) {
selectedCells.push({ row: row, col: col });
td.classList.add("cell-selected");
updateSelectionButtons();
return;
}
// Clicking the same cell — deselect
if (selectedCells[0].row === row && selectedCells[0].col === col) {
clearSelection();
return;
}
// Second click — compute line from start to end
var startR = selectedCells[0].row;
var startC = selectedCells[0].col;
var dr = row - startR;
var dc = col - startC;
// Validate straight line
var absDr = Math.abs(dr);
var absDc = Math.abs(dc);
if (absDr !== absDc && absDr !== 0 && absDc !== 0) {
showFeedback("Selection must form a straight line.", "text-warning");
clearSelection();
return;
}
var steps = Math.max(absDr, absDc);
if (steps === 0) return;
// Unit direction
var stepR = dr === 0 ? 0 : dr / absDr;
var stepC = dc === 0 ? 0 : dc / absDc;
// Build full cell list from start to end
var cells = [];
for (var i = 0; i <= steps; i++) {
cells.push({ row: startR + stepR * i, col: startC + stepC * i });
}
// Highlight all cells
cells.forEach(function (c) {
var cell = gridTable.querySelector(
'td[data-row="' + c.row + '"][data-col="' + c.col + '"]'
);
if (cell) cell.classList.add("cell-selected");
});
// Auto-submit
selectedCells = cells;
guessBtn.disabled = true;
sendGuess({ cells: cells }).then(function () {
guessBtn.disabled = false;
clearSelection();
});
}
function updateSelectionButtons() {
if (selectedCells.length > 0) {
clearSelectionBtn.classList.remove("d-none");
} else {
clearSelectionBtn.classList.add("d-none");
}
}
clearSelectionBtn.addEventListener("click", clearSelection);
function clearSelection() {
selectedCells = [];
document.querySelectorAll(".cell-selected").forEach(function (td) {
td.classList.remove("cell-selected");
});
updateSelectionButtons();
}
// Send guess to server
async function sendGuess(payload) {
try {
const resp = await fetch(BASE + "api/game/" + gameState.id + "/guess", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error("Server error");
const result = await resp.json();
gameState.words = result.words;
gameState.status = result.status;
if (result.correct) {
highlightFoundCells(result.cells);
renderWordList();
showFeedback("Found: " + titleCase(result.word) + "!", "text-success");
} else {
showFeedback("Not a match.", "text-danger");
}
if (result.status === "completed") {
gameState.completed_at = result.completed_at;
gameState.player_name = result.player_name;
onGameComplete(result.completed_at);
}
} catch (err) {
showFeedback("Request failed. Try again.", "text-danger");
}
}
function highlightFoundCells(cells) {
cells.forEach(function (c) {
const td = gridTable.querySelector(
'td[data-row="' + c.row + '"][data-col="' + c.col + '"]'
);
if (td) td.classList.add("cell-found");
});
}
function showFeedback(msg, cls) {
if (feedbackTimeout) clearTimeout(feedbackTimeout);
guessFeedback.textContent = msg;
guessFeedback.className = "mt-2 " + cls;
feedbackTimeout = setTimeout(function () {
guessFeedback.textContent = "";
guessFeedback.className = "mt-2";
feedbackTimeout = null;
}, 2000);
}
function onGameComplete(completedAt) {
stopTimer();
completionBanner.classList.remove("d-none");
finalTime.textContent = timerEl.textContent;
playAgainBtn.classList.remove("d-none");
inputArea.classList.add("d-none");
var startedAt = new Date(gameState.started_at);
var endedAt = new Date(completedAt);
var seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
saveRecentGame({
game_id: gameState.id,
player_name: gameState.player_name || "Anonymous",
category: gameState.category,
time_display: timerEl.textContent,
time_seconds: seconds,
board_size: gameState.board_size,
word_count: gameState.words.length,
completed_at: completedAt,
});
}
function saveRecentGame(entry) {
var KEY = "ws-recent-games";
try {
var games = JSON.parse(localStorage.getItem(KEY) || "[]");
games.unshift(entry);
if (games.length > 20) games = games.slice(0, 20);
localStorage.setItem(KEY, JSON.stringify(games));
} catch (e) { /* localStorage unavailable */ }
}
playAgainBtn.addEventListener("click", function () {
boardDiv.classList.add("d-none");
setupDiv.classList.remove("d-none");
});
// Init
loadCategories();
})();