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:
co-authored by
Claude Opus 4.6
parent
a65be26311
commit
2ee8afe582
@@ -11,7 +11,6 @@ from typing import Sequence, Union
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "71016fe57b9d"
|
revision: str = "71016fe57b9d"
|
||||||
down_revision: Union[str, Sequence[str], None] = None
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
|||||||
@@ -145,3 +145,80 @@ body {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 3rem;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Word Search{% end %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1WR6zNiELmFp5mGg7gCjSP54e0TpoS"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="{{ static_url('css/theme.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">Word Search</a>
|
||||||
|
<button class="navbar-toggler" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/">Home</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/game">Play</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/howtoplay">How to Play</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/about">About</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container py-4">
|
||||||
|
{% block content %}{% end %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Word Search — Play{% end %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Game Setup -->
|
||||||
|
<div id="game-setup">
|
||||||
|
<h2 class="mb-4">New Game</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="category-select" class="form-label">Category</label>
|
||||||
|
<select id="category-select" class="form-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="board-size" class="form-label">
|
||||||
|
Board Size: <span id="size-display">10</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="board-size" class="form-range"
|
||||||
|
min="8" max="20" value="10">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<button id="new-game-btn" class="btn btn-accent btn-lg w-100">
|
||||||
|
Start Game
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Board -->
|
||||||
|
<div id="game-board" class="d-none">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="mb-0">Word Search</h2>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span id="timer" class="badge bg-dark fs-6">0:00</span>
|
||||||
|
<button id="play-again-btn" class="btn btn-outline-light btn-sm d-none">
|
||||||
|
Play Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completion Banner -->
|
||||||
|
<div id="completion-banner" class="alert alert-success d-none" role="alert">
|
||||||
|
<strong>Congratulations!</strong> You found all the words in
|
||||||
|
<span id="final-time"></span>!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Grid -->
|
||||||
|
<div class="col-lg-8 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<table id="grid-table" class="game-grid mx-auto"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Area -->
|
||||||
|
<div class="card mt-3" id="input-area">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col">
|
||||||
|
<input type="text" id="word-input"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Type a word..."
|
||||||
|
autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button id="guess-btn" class="btn btn-accent">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button id="submit-selection-btn"
|
||||||
|
class="btn btn-outline-light d-none">
|
||||||
|
Submit Selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button id="clear-selection-btn"
|
||||||
|
class="btn btn-outline-secondary d-none">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="guess-feedback" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Word List -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Words to Find</div>
|
||||||
|
<ul id="word-list" class="list-group list-group-flush"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ static_url('js/game.js') }}"></script>
|
||||||
|
{% end %}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Word Search — Home{% end %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<h1 class="display-4 fw-bold mb-3">Word Search</h1>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Find hidden words in a grid of letters. Words can appear horizontally,
|
||||||
|
vertically, or diagonally. How fast can you find them all?
|
||||||
|
</p>
|
||||||
|
<a href="/game" class="btn btn-accent btn-lg px-5">Start Game</a>
|
||||||
|
</div>
|
||||||
|
{% end %}
|
||||||
Reference in New Issue
Block a user