diff --git a/alembic/versions/71016fe57b9d_initial_tables.py b/alembic/versions/71016fe57b9d_initial_tables.py index a3dee64..c2ffb88 100644 --- a/alembic/versions/71016fe57b9d_initial_tables.py +++ b/alembic/versions/71016fe57b9d_initial_tables.py @@ -11,7 +11,6 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa - # revision identifiers, used by Alembic. revision: str = "71016fe57b9d" down_revision: Union[str, Sequence[str], None] = None diff --git a/static/css/theme.css b/static/css/theme.css index 7eef097..9a4da8c 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -145,3 +145,80 @@ body { font-size: 0.85rem; margin-top: 3rem; } + +/* Game grid */ +.game-grid { + border-collapse: collapse; +} + +.game-grid td { + width: 2.5rem; + height: 2.5rem; + text-align: center; + vertical-align: middle; + font-size: 1.1rem; + font-weight: 600; + font-family: monospace; + border: 1px solid var(--indigo-mid); + cursor: pointer; + user-select: none; + transition: background-color 0.15s; +} + +.game-grid td:hover { + background-color: var(--indigo-mid); +} + +.game-grid td.cell-selected { + background-color: rgba(233, 69, 96, 0.3); + outline: 2px solid var(--coral); + outline-offset: -2px; +} + +.game-grid td.cell-found { + background-color: var(--coral); + color: #ffffff; +} + +/* Word list */ +.list-group-item { + background-color: var(--indigo-dark); + border-color: var(--indigo-mid); + color: var(--text-primary); +} + +.list-group-item.word-found { + text-decoration: line-through; + color: var(--coral); +} + +.card-header { + background-color: var(--indigo-mid); + border-color: var(--indigo-mid); + color: #ffffff; + font-weight: 600; +} + +/* Form controls in dark theme */ +.form-select, +.form-control { + background-color: var(--indigo-dark); + border-color: var(--indigo-mid); + color: var(--text-primary); +} + +.form-select:focus, +.form-control:focus { + background-color: var(--indigo-dark); + border-color: var(--coral); + color: var(--text-primary); + box-shadow: 0 0 0 0.2rem rgba(233, 69, 96, 0.25); +} + +.form-range::-webkit-slider-thumb { + background: var(--coral); +} + +.form-range::-moz-range-thumb { + background: var(--coral); +} diff --git a/static/js/game.js b/static/js/game.js new file mode 100644 index 0000000..799a90a --- /dev/null +++ b/static/js/game.js @@ -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(); +})(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..91e23b7 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,48 @@ + + + + + + {% block title %}Word Search{% end %} + + + + + + +
+ {% block content %}{% end %} +
+ + + + diff --git a/templates/game.html b/templates/game.html new file mode 100644 index 0000000..4b5a7e8 --- /dev/null +++ b/templates/game.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} + +{% block title %}Word Search — Play{% end %} + +{% block content %} + +
+

New Game

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+

Word Search

+
+ 0:00 + +
+
+ + + + +
+ +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ + +
+
+
Words to Find
+
    +
    +
    +
    +
    + + +{% end %} diff --git a/templates/main.html b/templates/main.html new file mode 100644 index 0000000..1a09a71 --- /dev/null +++ b/templates/main.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Word Search — Home{% end %} + +{% block content %} +
    +

    Word Search

    +

    + Find hidden words in a grid of letters. Words can appear horizontally, + vertically, or diagonally. How fast can you find them all? +

    + Start Game +
    +{% end %}