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>
258 lines
8.6 KiB
JavaScript
258 lines
8.6 KiB
JavaScript
(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();
|
|
})();
|