Files
upbeatBytes/frontend/src/lib/components/WordSearchGame.svelte
T
thejayman77 f43f645d69 Games batch: neutral words/themes, Word Search sizes + per-word colours
From playtesting findings:
* Pools nearly doubled (115/104 → 228/201) with calm/neutral everyday words
  (claps, dance, drench, beach…), not just strictly-upbeat ones — more variety,
  ~7-month runway. The post-solve "why" prompt reworded to fit neutral words.
* Word Search now stores one theme + word list per day; the grid is built per
  request for three SIZE tiers — Small (8×8, 6 words), Medium (11×11, 9),
  Large (14×14, 13). Large packs more words = a longer sit ("too fast" fix).
  All sizes share the day's theme; every size still code-placed + solvable.
* Word Search themes can now be neutral everyday scenes ("Around the house",
  "At the beach", "In the kitchen", "A walk outdoors", "Making music"…), not
  only hopeful — same shape as the articles.
* Each found word gets its own colour from a calm palette, in the grid and its
  word-list chip. Per-size local progress + best time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:32:53 -04:00

213 lines
8.5 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { getJSON } from '$lib/api.js';
import { lineFrom, matchWord } from '$lib/wordsearch.js';
let { size = 'med', onstatus } = $props();
// A calm, distinct palette — each found word gets its own colour.
const PALETTE = ['#a7d3c2', '#f4cf88', '#a9c8ec', '#eab0c0', '#cdb8e6',
'#f3a98a', '#c2d99a', '#e3b0d0', '#9fd0d8', '#d9c79a'];
let theme = $state('');
let words = $state([]);
let grid = $state([]); // array of row strings
let date = $state('');
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
let sel = $state([]); // current selection cells
let selecting = false;
let startTime = 0;
let resultMs = $state(0);
let best = $state(0);
let loading = $state(true);
let ready = $state(false);
let okFlash = $state(false);
let copied = $state(false);
let gridEl;
const n = $derived(grid.length);
const stateKey = $derived(`goodnews:wordsearch:${size}:${date}`);
const bestKey = $derived(`goodnews:wordsearch:best:${size}`);
const found = $derived(foundWords.map((w) => w.word));
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
const cellColor = $derived.by(() => {
const m = new Map();
for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci);
return m;
});
const wordColor = $derived.by(() => {
const m = new Map();
for (const w of foundWords) m.set(w.word, w.ci);
return m;
});
async function load() {
loading = true; ready = false;
foundWords = []; sel = []; resultMs = 0; startTime = 0;
try {
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
restore();
if (!startTime) startTime = Date.now();
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
} catch {
theme = ''; words = [];
}
loading = false;
requestAnimationFrame(() => (ready = true));
}
function restore() {
try {
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (s && Array.isArray(s.foundWords)) {
foundWords = s.foundWords;
startTime = s.startTime || 0;
resultMs = s.ms || 0;
}
} catch { /* ignore */ }
onstatus?.(summary());
}
function persist() {
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); }
catch { /* ignore */ }
onstatus?.(summary());
}
function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; }
function cellAt(e) {
const rect = gridEl.getBoundingClientRect();
const cw = rect.width / n;
const c = Math.min(n - 1, Math.max(0, Math.floor((e.clientX - rect.left) / cw)));
const r = Math.min(n - 1, Math.max(0, Math.floor((e.clientY - rect.top) / cw)));
return [r, c];
}
function down(e) {
if (status === 'done') return;
selecting = true;
if (!startTime) startTime = Date.now();
sel = [cellAt(e)];
gridEl.setPointerCapture?.(e.pointerId);
e.preventDefault();
}
function move(e) {
if (!selecting) return;
sel = lineFrom(sel[0], cellAt(e), n);
}
function up() {
if (!selecting) return;
selecting = false;
evaluate(sel);
sel = [];
}
function evaluate(cells) {
const hit = matchWord(cells, grid, words, found);
if (!hit) return;
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
okFlash = true; setTimeout(() => (okFlash = false), 500);
if (foundWords.length === words.length) finish();
persist();
}
function finish() {
resultMs = startTime ? Date.now() - startTime : 0;
if (resultMs && (!best || resultMs < best)) {
best = resultMs;
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
}
}
function fmt(ms) {
const s = Math.round(ms / 1000);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
function share() {
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
}
// Load on mount and whenever the size changes. Tracks ONLY `size`.
$effect(() => { size; load(); });
</script>
<div class="wordsearch" class:ready>
{#if loading}
<p class="muted">Loading todays word search…</p>
{:else if !words.length}
<p class="muted">Could not load todays word search.</p>
{:else}
<p class="theme"><span class="lbl">Todays theme</span>{theme}</p>
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{n}"
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
{#each grid as rowStr, r (r)}
{#each rowStr.split('') as ch, c (c)}
{@const key = r + ',' + c}
<div class="cell" class:sel={selSet.has(key)}
style={cellColor.has(key) && !selSet.has(key) ? `background:${PALETTE[cellColor.get(key)]};color:#2a2f36` : ''}>{ch}</div>
{/each}
{/each}
</div>
<ul class="words">
{#each words as w (w)}
<li class:got={found.includes(w)}
style={wordColor.has(w) ? `background:${PALETTE[wordColor.get(w)]};color:#2a2f36` : ''}>{w}</li>
{/each}
</ul>
{#if status === 'done'}
<div class="result rise">
<p class="rmark">✦ all found ✦</p>
<div class="times">
<div><span class="n">{fmt(resultMs)}</span><span class="l">your time</span></div>
{#if best}<div><span class="n">{fmt(best)}</span><span class="l">best</span></div>{/if}
</div>
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
</div>
{:else}
<p class="hint">{foundWords.length} / {words.length} found · drag across the letters</p>
{/if}
{/if}
</div>
<style>
.wordsearch { max-width: 460px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
.wordsearch.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
.theme { text-align: center; font-family: var(--serif); font-size: 1.3rem; color: var(--accent-deep); margin: 0 0 14px; }
.theme .lbl { display: block; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.64rem;
font-family: var(--label); color: var(--muted); margin-bottom: 2px; }
.grid {
display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 2px;
width: min(100%, 420px); margin: 0 auto 16px; touch-action: none; user-select: none;
-webkit-user-select: none; border-radius: 10px; padding: 6px; background: var(--surface);
border: 1px solid var(--line);
}
.grid.done { opacity: 0.9; }
.cell {
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
font-family: var(--label); font-weight: 600; font-size: clamp(0.62rem, 2.7vw, 1rem);
color: var(--ink); border-radius: 5px; background: transparent; text-transform: uppercase;
}
.cell.sel { background: var(--accent) !important; color: #fff !important; }
.words { list-style: none; display: flex; flex-wrap: wrap; gap: 7px 9px; justify-content: center;
padding: 0; margin: 0 0 14px; }
.words li { font-family: var(--label); font-size: 0.8rem; letter-spacing: 0.04em; color: var(--ink);
padding: 2px 9px; border-radius: 999px; }
.hint { text-align: center; color: var(--muted); font-size: 0.84rem; margin: 0; }
.result { text-align: center; }
.rmark { font-family: var(--serif); font-style: italic; color: var(--accent-deep); font-size: 1.2rem; margin: 0 0 12px; }
.times { display: flex; justify-content: center; gap: 26px; margin: 0 0 16px; }
.times .n { display: block; font-size: 1.5rem; font-weight: 700; font-family: var(--label); }
.times .l { color: var(--muted); font-size: 0.78rem; }
.share { background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 11px 26px; font: inherit; font-weight: 600; cursor: pointer; }
.share:hover { background: var(--accent-deep); }
.muted { color: var(--muted); text-align: center; }
</style>