59ff48ae90
Sharpen the existing daily-game share loop into something measurable (per Codex's
"instrument what you have, then feed people into it" plan), ahead of a Show HN launch.
Analytics:
- Per-game funnel events <game>_{arrival,started,completed,shared} (article_id=0).
arrival = landed via a shared link (utm_source=game_share); started = first move
(guess/find/flip); completed = solved/cleared/Full Bloom; shared = on share success.
- trackVisit() moved into the global layout so direct /play landings count; the
server-rendered /a/ share page now creates a visitor token + sends a daily visit
beacon (first-time /a/-only visitors were previously dropped).
- Admin "Games funnel" panel: arrivals / engaged / completed / shared, per game.
Sharing:
- Memory Match gains a Share button (it was the only game without one).
- All shares deep-link to the exact game+variant with a full https:// URL +
utm_source=game_share (gameShareUrl helper), instead of a bare /play.
- "shared" is counted only after navigator.share()/clipboard.writeText() succeeds.
/play social metadata:
- /play served homepage canonical/OG (static SPA, ssr=false). postbuild script
patches build/play.html's head to /play canonical/title/description/OG; fails the
build if the homepage tags drift. Caddy try_files now serves {path}.html so /play
is served from the patched file (snapshot in deploy/caddy/).
Tests: backend 352, frontend 27.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
13 KiB
Svelte
273 lines
13 KiB
Svelte
<script>
|
|
import { untrack } from 'svelte';
|
|
import { pushGameState } from '$lib/gamesync.js';
|
|
import { trackGame, gameShareUrl } from '$lib/analytics.js';
|
|
import { COLOR_BY_KEY } from '$lib/games/match/palette.js';
|
|
import { buildBoard, freeSeed } from '$lib/games/match/board.js';
|
|
import MatchIcon from './MatchIcon.svelte';
|
|
|
|
// mode: 'daily' (shared, synced, feeds the ritual) | 'free' (local-only, replayable)
|
|
// format: 'icons' | 'colors' · tier: 'gentle' | 'standard' | 'expert'
|
|
let { mode = 'daily', format = 'icons', tier = 'standard', date = '', onstatus } = $props();
|
|
|
|
let isFree = $derived(mode === 'free');
|
|
let variant = $derived(`${tier}-${format}`);
|
|
// Storage: daily keyed by the server date (cross-device synced); free keeps one
|
|
// resumable scratch board per variant.
|
|
let stateKey = $derived(isFree ? `goodnews:match:free:${variant}` : `goodnews:match:${variant}:${date}`);
|
|
const ASSIST_KEY = 'goodnews:match:assist';
|
|
|
|
let board = $state(null);
|
|
let seed = $state('');
|
|
let flipped = $state([]); // card ids face-up, not yet resolved
|
|
let matchedKeys = $state(new Set());
|
|
let moves = $state(0);
|
|
let locked = $state(false); // brief hold while a mismatch is shown
|
|
let ready = $state(false);
|
|
let assist = $state(false); // colorblind assist: show a tiny letter on swatches
|
|
let celebrated = $state(false);
|
|
|
|
let matchedIds = $derived(board ? new Set(board.cards.filter((c) => matchedKeys.has(c.key)).map((c) => c.id)) : new Set());
|
|
let done = $derived(!!board && matchedKeys.size >= board.faces.length);
|
|
|
|
function read(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }
|
|
function write(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* ignore */ } }
|
|
|
|
function emitStatus() {
|
|
onstatus?.({ count: matchedKeys.size, total: board?.faces.length ?? 0, moves, done });
|
|
}
|
|
|
|
function persist() {
|
|
const snap = { matched: [...matchedKeys], moves, done, seed: isFree ? seed : undefined };
|
|
write(stateKey, snap);
|
|
emitStatus();
|
|
// Daily resumes across devices; free play stays local.
|
|
if (!isFree && date) pushGameState('match', variant, date, { matched: snap.matched, moves, done });
|
|
}
|
|
|
|
let loadSeq = 0;
|
|
|
|
async function load(forceNew = false) {
|
|
const myLoad = ++loadSeq;
|
|
ready = false;
|
|
// A pending mismatch flip-back must not fire onto a freshly switched board.
|
|
if (flipTimer) { clearTimeout(flipTimer); flipTimer = null; }
|
|
try { assist = localStorage.getItem(ASSIST_KEY) === '1'; } catch { /* ignore */ }
|
|
|
|
const useFree = isFree;
|
|
let saved, theSeed;
|
|
if (useFree) {
|
|
saved = forceNew ? null : read(stateKey);
|
|
// a free board derives a stable seed so a reload resumes the same layout
|
|
theSeed = saved?.seed || freeSeed((Date.now() ^ Math.floor(Math.random() * 1e9)) >>> 0);
|
|
} else {
|
|
theSeed = date;
|
|
saved = read(stateKey);
|
|
// pull/merge the server's copy first (cross-device resume)
|
|
if (date) {
|
|
const merged = await pushGameState('match', variant, date, {
|
|
matched: saved?.matched || [], moves: saved?.moves || 0, done: !!saved?.done,
|
|
});
|
|
if (myLoad !== loadSeq) return; // a newer board load started — drop this stale one
|
|
if (merged) saved = merged;
|
|
}
|
|
}
|
|
if (myLoad !== loadSeq) return;
|
|
|
|
// Commit the new board + restored progress atomically AFTER any await, so a rapid
|
|
// daily/free, icon/color, or tier switch can't hydrate an old response onto the
|
|
// newer board.
|
|
const b = buildBoard({ format, tier, seed: theSeed });
|
|
const valid = new Set(b.faces);
|
|
board = b;
|
|
seed = theSeed;
|
|
flipped = []; locked = false;
|
|
matchedKeys = new Set((saved?.matched || []).filter((k) => valid.has(k)));
|
|
moves = Number.isFinite(saved?.moves) ? saved.moves : 0;
|
|
celebrated = matchedKeys.size >= b.faces.length; // don't replay the celebration on resume
|
|
// Persist immediately so a freshly-generated free board's seed survives a reload
|
|
// even before the first move.
|
|
write(stateKey, { matched: [...matchedKeys], moves, done: matchedKeys.size >= b.faces.length,
|
|
seed: useFree ? theSeed : undefined });
|
|
ready = true;
|
|
emitStatus();
|
|
}
|
|
|
|
function reset() { if (isFree) load(true); }
|
|
|
|
function toggleAssist() {
|
|
assist = !assist;
|
|
try { localStorage.setItem(ASSIST_KEY, assist ? '1' : '0'); } catch { /* ignore */ }
|
|
}
|
|
|
|
let flipTimer = null;
|
|
|
|
function flip(card) {
|
|
if (!ready || locked || done) return;
|
|
if (matchedKeys.has(card.key) || flipped.includes(card.id)) return;
|
|
if (moves === 0 && flipped.length === 0 && matchedKeys.size === 0) trackGame('match', 'started');
|
|
|
|
// Match off the LOCAL `next`, not the just-assigned reactive `flipped`, so the
|
|
// evaluation never runs a click behind.
|
|
const next = [...flipped, card.id];
|
|
flipped = next;
|
|
if (next.length < board.matchN) return; // Expert needs all 3 before judging
|
|
|
|
moves += 1;
|
|
const keys = next.map((id) => board.cards[id].key);
|
|
const allSame = keys.every((k) => k === keys[0]);
|
|
if (allSame) {
|
|
matchedKeys = new Set([...matchedKeys, keys[0]]);
|
|
flipped = [];
|
|
persist();
|
|
if (matchedKeys.size >= board.faces.length && !celebrated) { celebrated = true; trackGame('match', 'completed'); }
|
|
} else {
|
|
locked = true; // show the mismatch briefly, then flip back
|
|
persist();
|
|
flipTimer = setTimeout(() => { flipped = []; locked = false; flipTimer = null; }, 850);
|
|
}
|
|
}
|
|
|
|
const TIER_LABEL = { gentle: 'Gentle', standard: 'Standard', expert: 'Expert' };
|
|
let copied = $state(false);
|
|
function share() {
|
|
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
|
|
const when = isFree ? 'Free play' : date;
|
|
const text = `Upbeat Bytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
|
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
|
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
|
}
|
|
|
|
function faceLabel(card) {
|
|
if (format === 'colors') return COLOR_BY_KEY[card.key]?.name ?? 'color';
|
|
return card.key;
|
|
}
|
|
|
|
// Reload whenever the board identity (mode/format/tier/date, all encoded in
|
|
// stateKey) changes. untrack() keeps the effect from tracking load()'s internal
|
|
// state reads (board/matchedKeys/moves/done via emitStatus) — which load() also
|
|
// writes — so a synchronous free-play load can't self-retrigger into a loop.
|
|
$effect(() => {
|
|
stateKey;
|
|
untrack(() => load());
|
|
return () => { if (flipTimer) clearTimeout(flipTimer); };
|
|
});
|
|
</script>
|
|
|
|
<div class="match">
|
|
<div class="topbar">
|
|
<div class="meta">
|
|
{#if board}
|
|
<span class="tier">{board.label}{format === 'colors' ? ' · Color' : ''}</span>
|
|
<span class="prog">{matchedKeys.size}/{board.faces.length}{moves ? ` · ${moves} moves` : ''}</span>
|
|
<span class="rule" class:tri={board.matchN === 3}>{#if board.matchN === 3 && flipped.length > 0 && flipped.length < 3}Pick 3 · {flipped.length}/3{:else}{board.matchN === 3 ? 'Match 3 of a kind' : 'Match pairs'}{/if}</span>
|
|
{/if}
|
|
</div>
|
|
<div class="controls">
|
|
{#if format === 'colors'}
|
|
<button class="ctl" aria-pressed={assist} onclick={toggleAssist}>Assist {assist ? 'on' : 'off'}</button>
|
|
{/if}
|
|
{#if isFree}
|
|
<button class="ctl" onclick={reset}>New board</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if board}
|
|
<div class="grid" class:locked
|
|
style="grid-template-columns: repeat({board.cols}, 1fr); max-width: {board.cols * 76}px;">
|
|
{#each board.cards as card (card.id)}
|
|
{@const up = matchedIds.has(card.id) || flipped.includes(card.id)}
|
|
{@const matched = matchedIds.has(card.id)}
|
|
<button class="card" class:up class:matched
|
|
aria-label={up ? faceLabel(card) : 'card, face down'}
|
|
disabled={matched}
|
|
onclick={() => flip(card)}>
|
|
<span class="inner">
|
|
<span class="back" aria-hidden="true"></span>
|
|
<span class="front" class:colored={format === 'colors'} aria-hidden="true">
|
|
{#if format === 'colors'}
|
|
<span class="colorfill" style="--c: {COLOR_BY_KEY[card.key]?.hex}">
|
|
{#if assist}<span class="assist">{COLOR_BY_KEY[card.key]?.assist}</span>{/if}
|
|
</span>
|
|
{:else}
|
|
<MatchIcon name={card.key} />
|
|
{/if}
|
|
{#if matched}<span class="check" aria-hidden="true">✓</span>{/if}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if done && celebrated}
|
|
<p class="done">Lovely — you cleared {isFree ? 'the board' : "today's set"}.{isFree ? '' : ' Fresh one tomorrow.'}</p>
|
|
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.match { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
|
.topbar { width: 100%; max-width: 460px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
.meta { display: flex; flex-direction: column; gap: 1px; }
|
|
.tier { font-family: var(--label); font-weight: 600; }
|
|
.prog { color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
|
|
.rule { font-size: 0.8rem; color: var(--muted); margin-top: 2px; }
|
|
/* Expert's 3-of-a-kind rule is the easy-to-forget one — give it a clear chip. */
|
|
.rule.tri { align-self: flex-start; color: var(--accent-deep); background: var(--accent-soft);
|
|
padding: 1px 9px; border-radius: 999px; font-weight: 600; }
|
|
.controls { display: flex; gap: 8px; }
|
|
.ctl { font-size: 0.8rem; padding: 5px 11px; border: 1px solid var(--line); border-radius: 9px;
|
|
background: var(--surface); color: var(--accent-deep); cursor: pointer; }
|
|
|
|
.grid { display: grid; gap: 8px; width: 100%; }
|
|
.card { aspect-ratio: 1; min-width: 0; padding: 0; border: none; background: none; cursor: pointer;
|
|
perspective: 600px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
|
.card[disabled] { cursor: default; }
|
|
.inner { position: relative; display: block; width: 100%; height: 100%;
|
|
transition: transform 0.32s ease; transform-style: preserve-3d; }
|
|
.card.up .inner { transform: rotateY(180deg); }
|
|
.back, .front { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
border-radius: 13px; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
|
|
/* Face-down: a branded card back — azure gradient under a faint dot motif. */
|
|
.back { background:
|
|
radial-gradient(circle at 50% 50%, rgba(255,255,255,0.18) 1.2px, transparent 1.6px) 0 0 / 11px 11px,
|
|
linear-gradient(150deg, var(--accent) 0%, var(--accent-deep) 100%);
|
|
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18); }
|
|
.front { background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
|
|
transform: rotateY(180deg); }
|
|
.front.colored { background: none; border: none; } /* the colorfill is the whole face */
|
|
/* Icon front-and-center: ~70% of the card (≈50% area) so the symbol is memorable. */
|
|
.front :global(svg) { width: 70%; height: 70%; }
|
|
.colorfill { position: absolute; inset: 0; border-radius: 13px; background: var(--c);
|
|
border: 2px solid #3a3a3a; }
|
|
.assist { position: absolute; top: 5px; right: 6px; font-family: var(--label); font-weight: 700;
|
|
font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.45); padding: 1px 5px; border-radius: 6px; }
|
|
|
|
/* State hierarchy ----------------------------------------------------------
|
|
Inspecting (flipped, unmatched): vivid + lifted, so the unknown cards pop. */
|
|
.card.up:not(.matched) .front:not(.colored) { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
|
|
.card.up:not(.matched) .colorfill { box-shadow: 0 2px 8px rgba(0,0,0,0.20); }
|
|
/* Matched: a settled "paint-chip" tile — receded, desaturated, with a quiet check,
|
|
so the last few unknown cards stay obvious instead of blending in. */
|
|
.card.matched .inner { transform: rotateY(180deg) scale(0.9); }
|
|
.card.matched .front { box-shadow: none; }
|
|
.card.matched .front.colored { background: var(--surface); border: 1px solid var(--line); }
|
|
.card.matched .colorfill { inset: 5px; border-radius: 8px; border: 1px solid rgba(0,0,0,0.22);
|
|
filter: saturate(0.6) brightness(1.06); box-shadow: none; }
|
|
.card.matched :global(svg) { opacity: 0.42; }
|
|
.card.matched .assist { display: none; }
|
|
.check { position: absolute; bottom: 4px; right: 6px; font-size: 0.82rem; line-height: 1;
|
|
color: var(--accent-deep); font-weight: 700; }
|
|
|
|
.done { margin: 4px 0 0; color: var(--accent-deep); font-family: var(--label); text-align: center; }
|
|
.share { margin-top: 10px; background: var(--accent); color: #fff; border: none; border-radius: 999px;
|
|
padding: 9px 22px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
.share:hover { background: var(--accent-deep); }
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.inner { transition: none; }
|
|
}
|
|
</style>
|