feat: add game page frontend with grid, word list, timer, and dual input

Recreates base.html and main.html templates (lost during reset).
Adds game.html with setup UI (category picker, board size slider),
interactive grid table, word list sidebar, text input, click-to-select,
timer, and completion banner. Adds game.js for all frontend logic.
Extends theme.css with game grid and form control styles using the
indigo/coral palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mathew Sir Guest the best
2026-05-06 22:56:13 -06:00
co-authored by Claude Opus 4.6
parent a65be26311
commit 2ee8afe582
6 changed files with 500 additions and 1 deletions
+257
View File
@@ -0,0 +1,257 @@
(function () {
"use strict";
let gameState = null;
let timerInterval = null;
let selectedCells = [];
// 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 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 submitSelectionBtn = document.getElementById("submit-selection-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");
// Load categories
async function loadCategories() {
const resp = await fetch("/api/categories");
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);
});
}
boardSizeInput.addEventListener("input", function () {
sizeDisplay.textContent = this.value;
});
// New game
newGameBtn.addEventListener("click", async function () {
const categoryId = parseInt(categorySelect.value);
const boardSize = parseInt(boardSizeInput.value);
const resp = await fetch("/api/game/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category_id: categoryId, board_size: boardSize }),
});
gameState = await resp.json();
showBoard();
});
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 = 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 = "";
await sendGuess({ word: word });
}
// Click selection
function onCellClick(row, col, td) {
if (gameState.status === "completed") return;
// Check if already selected — deselect
const idx = selectedCells.findIndex(
function (c) { return c.row === row && c.col === col; }
);
if (idx !== -1) {
selectedCells.splice(idx, 1);
td.classList.remove("cell-selected");
updateSelectionButtons();
return;
}
// Validate straight line
if (selectedCells.length >= 2) {
if (!isValidExtension(row, col)) {
showFeedback("Selection must form a straight line.", "text-warning");
return;
}
}
selectedCells.push({ row: row, col: col });
td.classList.add("cell-selected");
updateSelectionButtons();
}
function isValidExtension(row, col) {
if (selectedCells.length < 2) return true;
const dr = selectedCells[1].row - selectedCells[0].row;
const dc = selectedCells[1].col - selectedCells[0].col;
const len = selectedCells.length;
const expectedRow = selectedCells[0].row + dr * len;
const expectedCol = selectedCells[0].col + dc * len;
return row === expectedRow && col === expectedCol;
}
function updateSelectionButtons() {
if (selectedCells.length > 0) {
submitSelectionBtn.classList.remove("d-none");
clearSelectionBtn.classList.remove("d-none");
} else {
submitSelectionBtn.classList.add("d-none");
clearSelectionBtn.classList.add("d-none");
}
}
submitSelectionBtn.addEventListener("click", async function () {
if (selectedCells.length === 0) return;
await sendGuess({ cells: selectedCells });
clearSelection();
});
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) {
const resp = await fetch("/api/game/" + gameState.id + "/guess", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await resp.json();
gameState.words = result.words;
gameState.status = result.status;
if (result.correct) {
highlightFoundCells(result.cells);
renderWordList();
showFeedback("Found: " + result.word + "!", "text-success");
} else {
showFeedback("Not a match.", "text-danger");
}
if (result.status === "completed") {
onGameComplete(result.completed_at);
}
}
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) {
guessFeedback.textContent = msg;
guessFeedback.className = "mt-2 " + cls;
setTimeout(function () {
guessFeedback.textContent = "";
guessFeedback.className = "mt-2";
}, 2000);
}
function onGameComplete(completedAt) {
stopTimer();
completionBanner.classList.remove("d-none");
finalTime.textContent = timerEl.textContent;
playAgainBtn.classList.remove("d-none");
inputArea.classList.add("d-none");
}
playAgainBtn.addEventListener("click", function () {
boardDiv.classList.add("d-none");
setupDiv.classList.remove("d-none");
});
// Init
loadCategories();
})();