- Move word data from hardcoded seed.py to data/words.json - Expand from 210 words across 7 categories to 1014 words across 14 categories (animals, food, colors, sports, space, nature, music, science, countries, occupations, ocean, weather, mythology, technology) - Word list sidebar now shows title case (Tiger) while grid stays uppercase; text input remains case-insensitive - Add Ocean theme (light, blue accents) and Forest theme (dark, amber accents on deep green) - Replace hardcoded rgba values with --coral-bg CSS variable so selection highlighting adapts per theme Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
344 lines
12 KiB
JavaScript
344 lines
12 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
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("/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("/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("/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();
|
|
})();
|