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>
This commit is contained in:
@@ -2,16 +2,18 @@
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import { lineFrom, matchWord } from '$lib/wordsearch.js';
|
||||
|
||||
let { onstatus } = $props();
|
||||
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 size = $state(10);
|
||||
let grid = $state([]); // array of row strings
|
||||
let date = $state('');
|
||||
let found = $state([]); // found words
|
||||
let foundCells = $state(new Set()); // "r,c"
|
||||
let sel = $state([]); // current selection cells [[r,c]]
|
||||
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
|
||||
let sel = $state([]); // current selection cells
|
||||
let selecting = false;
|
||||
let startTime = 0;
|
||||
let resultMs = $state(0);
|
||||
@@ -19,21 +21,35 @@
|
||||
let loading = $state(true);
|
||||
let ready = $state(false);
|
||||
let okFlash = $state(false);
|
||||
let copied = $state(false);
|
||||
let gridEl;
|
||||
|
||||
const stateKey = $derived(`goodnews:wordsearch:${date}`);
|
||||
const BEST_KEY = 'goodnews:wordsearch:best';
|
||||
const status = $derived(words.length && found.length === words.length ? 'done' : 'playing');
|
||||
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');
|
||||
theme = p.theme; words = p.words; grid = p.grid; size = p.size; date = p.date;
|
||||
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(BEST_KEY) || '0'); } catch { best = 0; }
|
||||
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
|
||||
} catch {
|
||||
theme = ''; words = [];
|
||||
}
|
||||
@@ -44,9 +60,8 @@
|
||||
function restore() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||||
if (s && Array.isArray(s.found)) {
|
||||
found = s.found;
|
||||
foundCells = new Set(s.cells || []);
|
||||
if (s && Array.isArray(s.foundWords)) {
|
||||
foundWords = s.foundWords;
|
||||
startTime = s.startTime || 0;
|
||||
resultMs = s.ms || 0;
|
||||
}
|
||||
@@ -54,19 +69,17 @@
|
||||
onstatus?.(summary());
|
||||
}
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(stateKey, JSON.stringify(
|
||||
{ found, cells: [...foundCells], startTime, ms: resultMs, status }));
|
||||
} catch { /* ignore */ }
|
||||
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); }
|
||||
catch { /* ignore */ }
|
||||
onstatus?.(summary());
|
||||
}
|
||||
function summary() { return { game: 'wordsearch', date, status, found: found.length, total: words.length, ms: resultMs }; }
|
||||
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 / size;
|
||||
const c = Math.min(size - 1, Math.max(0, Math.floor((e.clientX - rect.left) / cw)));
|
||||
const r = Math.min(size - 1, Math.max(0, Math.floor((e.clientY - rect.top) / cw)));
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -80,7 +93,7 @@
|
||||
}
|
||||
function move(e) {
|
||||
if (!selecting) return;
|
||||
sel = lineFrom(sel[0], cellAt(e), size);
|
||||
sel = lineFrom(sel[0], cellAt(e), n);
|
||||
}
|
||||
function up() {
|
||||
if (!selecting) return;
|
||||
@@ -92,12 +105,9 @@
|
||||
function evaluate(cells) {
|
||||
const hit = matchWord(cells, grid, words, found);
|
||||
if (!hit) return;
|
||||
found = [...found, hit];
|
||||
const fc = new Set(foundCells);
|
||||
cells.forEach(([r, c]) => fc.add(r + ',' + c));
|
||||
foundCells = fc;
|
||||
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
|
||||
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
||||
if (found.length === words.length) finish();
|
||||
if (foundWords.length === words.length) finish();
|
||||
persist();
|
||||
}
|
||||
|
||||
@@ -105,7 +115,7 @@
|
||||
resultMs = startTime ? Date.now() - startTime : 0;
|
||||
if (resultMs && (!best || resultMs < best)) {
|
||||
best = resultMs;
|
||||
try { localStorage.setItem(BEST_KEY, JSON.stringify(best)); } catch { /* ignore */ }
|
||||
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,14 +124,15 @@
|
||||
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||
}
|
||||
|
||||
let copied = $state(false);
|
||||
function share() {
|
||||
const text = `Upbeat Bytes · Word Search ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
|
||||
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); });
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
// Load on mount and whenever the size changes. Tracks ONLY `size`.
|
||||
$effect(() => { size; load(); });
|
||||
</script>
|
||||
|
||||
<div class="wordsearch" class:ready>
|
||||
@@ -132,18 +143,21 @@
|
||||
{:else}
|
||||
<p class="theme"><span class="lbl">Today’s theme</span>{theme}</p>
|
||||
|
||||
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{size}"
|
||||
<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)}
|
||||
<div class="cell" class:found={foundCells.has(r + ',' + c)} class:sel={selSet.has(r + ',' + c)}>{ch}</div>
|
||||
{@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)}>{w}</li>
|
||||
<li class:got={found.includes(w)}
|
||||
style={wordColor.has(w) ? `background:${PALETTE[wordColor.get(w)]};color:#2a2f36` : ''}>{w}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -157,7 +171,7 @@
|
||||
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="hint">{found.length} / {words.length} found · drag across the letters</p>
|
||||
<p class="hint">{foundWords.length} / {words.length} found · drag across the letters</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -170,22 +184,21 @@
|
||||
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%, 360px); margin: 0 auto 16px; touch-action: none; user-select: none;
|
||||
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.85; }
|
||||
.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.8rem, 3.6vw, 1.05rem);
|
||||
color: var(--ink); border-radius: 6px; background: transparent; text-transform: uppercase;
|
||||
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.found { background: var(--accent-soft); color: var(--accent-deep); }
|
||||
.cell.sel { background: var(--accent); color: #fff; }
|
||||
.words { list-style: none; display: flex; flex-wrap: wrap; gap: 7px 12px; justify-content: center;
|
||||
.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.82rem; letter-spacing: 0.04em; color: var(--ink); }
|
||||
.words li.got { color: var(--muted); text-decoration: line-through; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user