word-waltz / commit
added solo mode
src/components/SoloScoreboard.ts | 134 ++++++++++++++++++++++++++++++ src/components/WordleBoard.ts | 17 ++++ src/main.ts | 172 +++++++++++++++++++++++++++++++++++++-- src/styles/main.css | 44 ++++++++++ src/types.ts | 21 +++++ src/utils/wordList.ts | 7 ++ 6 files changed, 390 insertions(+), 5 deletions(-) diff --git a/src/components/SoloScoreboard.ts b/src/components/SoloScoreboard.ts new file mode 100644 index 0000000..d7b7d9e --- /dev/null +++ b/src/components/SoloScoreboard.ts @@ -0,0 +1,134 @@ +/** + * SoloScoreboard — scoreboard for single-player mode. + * + * Shows the player's name (editable), their running total score, and the + * round history (collapsed to the last 2 rounds by default). + */ + +import type { SoloRound } from '../types.js'; + +interface SoloScoreboardProps { + playerName: string; + totalScore: number; + rounds: SoloRound[]; + onNameChange: (newName: string) => void; +} + +const COLLAPSED_COUNT = 2; + +export function mountSoloScoreboard( + container: HTMLElement, + props: SoloScoreboardProps, +): () => void { + const { rounds, onNameChange } = props; + let { playerName, totalScore } = props; + + const root = document.createElement('div'); + root.className = 'scoreboard'; + + const completedRounds = rounds.filter((r) => r.guesses.length > 0); + let expanded = false; + + function render(): void { + root.innerHTML = ''; + + // --- Player row --- + const playersEl = document.createElement('div'); + playersEl.className = 'scoreboard__players'; + + const card = document.createElement('div'); + card.className = 'scoreboard__player'; + + // Editable name + const nameRow = document.createElement('div'); + nameRow.className = 'scoreboard__name-row'; + + const nameEl = document.createElement('span'); + nameEl.className = 'scoreboard__name'; + nameEl.textContent = playerName || 'Player'; + nameRow.appendChild(nameEl); + + const editBtn = document.createElement('button'); + editBtn.className = 'scoreboard__edit-btn'; + editBtn.title = 'Change name'; + editBtn.textContent = '✎'; + editBtn.addEventListener('click', () => { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'scoreboard__name-input'; + input.value = playerName; + input.maxLength = 20; + nameRow.innerHTML = ''; + nameRow.appendChild(input); + input.focus(); + input.select(); + + function commit() { + const val = input.value.trim(); + if (val && val !== playerName) { + playerName = val; + onNameChange(val); + } else { + render(); + } + } + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + if (e.key === 'Escape') { input.removeEventListener('blur', commit); render(); } + }); + }); + nameRow.appendChild(editBtn); + card.appendChild(nameRow); + + const scoreEl = document.createElement('span'); + scoreEl.className = 'scoreboard__score'; + scoreEl.textContent = String(totalScore); + card.appendChild(scoreEl); + + playersEl.appendChild(card); + root.appendChild(playersEl); + + // --- History --- + if (completedRounds.length === 0) return; + + const visible = expanded + ? completedRounds + : completedRounds.slice(-COLLAPSED_COUNT); + + const historyEl = document.createElement('div'); + historyEl.className = 'scoreboard__history-wrap'; + + if (completedRounds.length > COLLAPSED_COUNT) { + const toggle = document.createElement('button'); + toggle.className = 'scoreboard__toggle'; + toggle.textContent = expanded + ? '▲ Hide older rounds' + : `▼ Show all ${completedRounds.length} rounds`; + toggle.addEventListener('click', () => { expanded = !expanded; render(); }); + historyEl.appendChild(toggle); + } + + const ol = document.createElement('ol'); + ol.className = 'scoreboard__history'; + ol.start = expanded ? 1 : completedRounds.length - visible.length + 1; + visible.forEach((r) => { + const result = r.solved + ? `solved "${r.word}" in ${r.guesses.length} ${r.guesses.length === 1 ? 'guess' : 'guesses'} (+${r.score})` + : `failed to solve "${r.word}"`; + const li = document.createElement('li'); + li.className = 'scoreboard__round'; + li.textContent = result; + ol.appendChild(li); + }); + historyEl.appendChild(ol); + root.appendChild(historyEl); + } + + render(); + container.appendChild(root); + + return function cleanup() { + container.removeChild(root); + }; +} diff --git a/src/components/WordleBoard.ts b/src/components/WordleBoard.ts index 5708506..604c412 100644 --- a/src/components/WordleBoard.ts +++ b/src/components/WordleBoard.ts @@ -88,10 +88,16 @@ export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps const message = document.createElement('p'); message.className = 'wordle-board__message'; + const giveUpBtn = document.createElement('button'); + giveUpBtn.type = 'button'; + giveUpBtn.className = 'wordle-board__give-up'; + giveUpBtn.textContent = 'Give up'; + const keyboardEl = document.createElement('div'); root.appendChild(grid); root.appendChild(message); + root.appendChild(giveUpBtn); root.appendChild(keyboardEl); container.appendChild(root); @@ -102,6 +108,15 @@ export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps const letterStates: Record<string, LetterState> = {}; const guesses: string[] = []; + giveUpBtn.addEventListener('click', () => { + if (done) return; + if (!confirm('Give up this round? The word will be revealed and you\'ll score 0.')) return; + done = true; + message.textContent = `The word was ${secret}`; + giveUpBtn.hidden = true; + onRoundEnd({ solved: false, guesses, score: 0 }); + }); + // Mount keyboard — it sends keys back via onKey const cleanupKeyboard = mountKeyboard(keyboardEl, { onKey: handleKey, @@ -164,10 +179,12 @@ export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps const solved = states.every((s) => s === 'correct'); if (solved) { done = true; + giveUpBtn.hidden = true; message.textContent = PRAISE[Math.min(guesses.length - 1, PRAISE.length - 1)]; scheduleRoundEnd(true); } else if (currentRow >= MAX_GUESSES) { done = true; + giveUpBtn.hidden = true; message.textContent = `The word was ${secret}`; scheduleRoundEnd(false); } diff --git a/src/main.ts b/src/main.ts index 8f2fc35..7648150 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,9 +16,11 @@ import { buildLink } from './utils/shareLink.js'; import { mountWordPicker } from './components/WordPicker.js'; import { mountWordleBoard } from './components/WordleBoard.js'; import { mountScoreboard } from './components/Scoreboard.js'; +import { mountSoloScoreboard } from './components/SoloScoreboard.js'; import { mountShareDialog } from './components/ShareDialog.js'; import { notifyMyTurn, clearNotification } from './utils/notifications.js'; -import type { GameState, PlayerId, Round } from './types.js'; +import { getRandomWord } from './utils/wordList.js'; +import type { GameState, PlayerId, Round, SoloGameState, SoloRound } from './types.js'; // --------------------------------------------------------------------------- // Waiting status helper @@ -243,6 +245,107 @@ function render( }; } +// --------------------------------------------------------------------------- +// Solo mode — purely local, no Firestore +// --------------------------------------------------------------------------- + +const SOLO_KEY = 'word-waltz:solo'; + +function loadSoloGame(): SoloGameState | null { + try { + const raw = localStorage.getItem(SOLO_KEY); + return raw ? (JSON.parse(raw) as SoloGameState) : null; + } catch { + return null; + } +} + +function saveSoloGame(state: SoloGameState): void { + localStorage.setItem(SOLO_KEY, JSON.stringify(state)); +} + +function deleteSoloGame(): void { + localStorage.removeItem(SOLO_KEY); +} + +function showSoloGame(app: HTMLElement): void { + unsubscribeGame?.(); + unsubscribeGame = null; + cleanupView?.(); + cleanupView = null; + app.innerHTML = ''; + + let state = loadSoloGame(); + if (!state) { + const name = getSavedName(); + const firstWord = getRandomWord(); + const firstRound: SoloRound = { word: firstWord, guesses: [], solved: false, score: 0 }; + state = { + playerName: name || 'You', + totalScore: 0, + rounds: [firstRound], + currentRound: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + saveSoloGame(state); + } + + function renderSolo(current: SoloGameState): void { + cleanupView?.(); + cleanupView = null; + app.innerHTML = ''; + + const scoreboardEl = document.createElement('div'); + app.appendChild(scoreboardEl); + const cleanupScoreboard = mountSoloScoreboard(scoreboardEl, { + playerName: current.playerName, + totalScore: current.totalScore, + rounds: current.rounds, + onNameChange: (newName: string) => { + saveName(newName); + const updated = { ...current, playerName: newName, updatedAt: Date.now() }; + saveSoloGame(updated); + renderSolo(updated); + }, + }); + + const mainEl = document.createElement('div'); + mainEl.className = 'main-view'; + app.appendChild(mainEl); + + const round = current.rounds[current.currentRound]; + + const cleanupBoard = mountWordleBoard(mainEl, { + secretWord: round.word, + onRoundEnd: (result) => { + const updatedRounds: SoloRound[] = current.rounds.map((r, i) => + i === current.currentRound ? { ...r, ...result } : r, + ); + const newTotalScore = current.totalScore + result.score; + const nextWord = getRandomWord(); + const nextRound: SoloRound = { word: nextWord, guesses: [], solved: false, score: 0 }; + const updated: SoloGameState = { + ...current, + totalScore: newTotalScore, + rounds: [...updatedRounds, nextRound], + currentRound: updatedRounds.length, + updatedAt: Date.now(), + }; + saveSoloGame(updated); + renderSolo(updated); + }, + }); + + cleanupView = () => { + cleanupScoreboard(); + cleanupBoard(); + }; + } + + renderSolo(state); +} + // --------------------------------------------------------------------------- // Home screen — game list + new game form // --------------------------------------------------------------------------- @@ -254,9 +357,12 @@ function showHomeScreen(app: HTMLElement): void { root.className = 'home-screen'; app.appendChild(root); - // --- Existing game list --- + // --- Game list (multiplayer + solo merged) --- const games = getStoredGames(); - if (games.length > 0) { + const existingSolo = loadSoloGame(); + const hasAnything = games.length > 0 || existingSolo !== null; + + if (hasAnything) { const listSection = document.createElement('div'); listSection.className = 'game-list'; @@ -274,6 +380,7 @@ function showHomeScreen(app: HTMLElement): void { if (!confirm('Delete all games? This cannot be undone.')) return; const all = getStoredGames(); clearStoredGames(); + deleteSoloGame(); for (const g of all) { deleteGame(g.gameId).catch((err: unknown) => console.warn('Firestore delete failed for', g.gameId, err)); @@ -286,6 +393,49 @@ function showHomeScreen(app: HTMLElement): void { const ul = document.createElement('ul'); ul.className = 'game-list__items'; + // --- Solo card (always first if it exists) --- + if (existingSolo) { + const completedCount = existingSolo.rounds.filter(r => r.guesses.length > 0).length; + const li = document.createElement('li'); + li.className = 'game-card game-card--solo'; + + const body = document.createElement('div'); + body.className = 'game-card__body'; + body.setAttribute('role', 'button'); + body.tabIndex = 0; + body.addEventListener('click', () => showSoloGame(app)); + body.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') showSoloGame(app); + }); + + const labelEl = document.createElement('span'); + labelEl.className = 'game-card__opponent'; + labelEl.textContent = 'Solo'; + body.appendChild(labelEl); + + const statusEl = document.createElement('span'); + statusEl.className = 'game-card__status game-card__status--my-turn'; + statusEl.textContent = completedCount === 0 + ? 'New game — your turn' + : `Score: ${existingSolo.totalScore} · ${completedCount} round${completedCount === 1 ? '' : 's'}`; + body.appendChild(statusEl); + + const delBtn = document.createElement('button'); + delBtn.className = 'game-card__delete'; + delBtn.type = 'button'; + delBtn.setAttribute('aria-label', 'Delete solo game'); + delBtn.textContent = '×'; + delBtn.addEventListener('click', () => { + deleteSoloGame(); + li.remove(); + if (getStoredGames().length === 0) showHomeScreen(app); + }); + + li.appendChild(body); + li.appendChild(delBtn); + ul.appendChild(li); + } + for (const game of games) { const li = document.createElement('li'); li.className = 'game-card'; @@ -319,7 +469,7 @@ function showHomeScreen(app: HTMLElement): void { deleteGame(game.gameId).catch((err: unknown) => console.warn('Firestore delete failed for', game.gameId, err)); li.remove(); - if (getStoredGames().length === 0) showHomeScreen(app); + if (getStoredGames().length === 0 && !loadSoloGame()) showHomeScreen(app); }); li.appendChild(body); @@ -340,7 +490,6 @@ function showHomeScreen(app: HTMLElement): void { newGameSection.appendChild(desc); const form = document.createElement('form'); - const label = document.createElement('label'); label.appendChild(document.createTextNode('Your name')); const nameInput = document.createElement('input'); @@ -357,6 +506,19 @@ function showHomeScreen(app: HTMLElement): void { submitBtn.textContent = 'Create game'; form.appendChild(submitBtn); newGameSection.appendChild(form); + + const orSolo = document.createElement('p'); + orSolo.className = 'new-game__or'; + orSolo.textContent = 'or'; + newGameSection.appendChild(orSolo); + + const soloBtn = document.createElement('button'); + soloBtn.type = 'button'; + soloBtn.className = 'new-game__solo-btn'; + soloBtn.textContent = 'Play Solo'; + soloBtn.addEventListener('click', () => showSoloGame(app)); + newGameSection.appendChild(soloBtn); + root.appendChild(newGameSection); form.addEventListener('submit', async (e) => { diff --git a/src/styles/main.css b/src/styles/main.css index 346e0a7..0da06f2 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -346,6 +346,21 @@ header { text-align: center; } +.wordle-board__give-up { + background: none; + border: none; + padding: 0; + font-size: 0.8rem; + color: var(--color-muted); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.wordle-board__give-up:hover { + color: var(--color-error); +} + /* --- Keyboard --------------------------------------------- */ /* 10 keys + 9 gaps per row; #app has 12px padding on each side (24px total). key-w = (100vw - 24px - 9 × 5px) / 10 = 10vw - 6.9px, capped at 43px. @@ -711,4 +726,33 @@ body:has(.home-screen) #app { .site-footer a:hover { text-decoration: underline; +} + +/* --- Solo game card accent -------------------------------- */ +.game-card--solo { + border-left: 3px solid var(--color-present); +} + +/* --- Solo button in new-game form ------------------------- */ +.new-game__or { + font-size: 0.8rem; + color: var(--color-muted); + margin: 0; +} + +.new-game__solo-btn { + padding: 8px 20px; + background: none; + color: var(--color-present); + border: 2px solid var(--color-present); + border-radius: var(--radius); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + width: 100%; +} + +.new-game__solo-btn:hover { + background: var(--color-present); + color: #fff; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 37d3ff9..3328ab5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,27 @@ export interface GameState { updatedAt: number; } +// --------------------------------------------------------------------------- +// Solo mode — stored locally in localStorage, no Firestore involved. +// --------------------------------------------------------------------------- + +export interface SoloRound { + word: string; + guesses: string[]; + solved: boolean; + score: number; +} + +export interface SoloGameState { + playerName: string; + totalScore: number; + rounds: SoloRound[]; + /** Index of the round currently being played (last element of rounds[]). */ + currentRound: number; + createdAt: number; + updatedAt: number; +} + /** The shape stored in Firestore (encrypted envelope + one plaintext field). */ export interface EncryptedEnvelope { iv: string; diff --git a/src/utils/wordList.ts b/src/utils/wordList.ts index 914c730..b720deb 100644 --- a/src/utils/wordList.ts +++ b/src/utils/wordList.ts @@ -8512,3 +8512,10 @@ const WORDS = new Set<string>([ export function isValidWord(word: string): boolean { return word.length === 5 && WORDS.has(word.toLowerCase()); } + +const WORDS_ARRAY: string[] = Array.from(WORDS); + +/** Returns a random word from the word list, upper-cased. */ +export function getRandomWord(): string { + return WORDS_ARRAY[Math.floor(Math.random() * WORDS_ARRAY.length)].toUpperCase(); +}