feat(master): add auto-call countdown indicator

Visible countdown ring + seconds number above the hero token while
auto-call runs. Host now sees exactly when the next number fires
instead of staring at a static caption.

- AutoCountdown.svelte: pure visual component, props-driven, rAF loop,
  SVG ring via stroke-dashoffset, prefers-reduced-motion fallback
- MasterPanel.svelte: tickCount $state bumped per draw + on every
  (re-)arm of the auto-call $effect (covers speed-slider mid-run)
This commit is contained in:
2026-04-30 19:32:02 +07:00
parent 7f5b4bac95
commit 7eb96808b0
3 changed files with 124 additions and 1 deletions
+2 -1
View File
@@ -14,7 +14,8 @@
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid rendered as 3 stacked 3×9 mini-cards: Tân Tân / An khang thịnh vượng / Tân Tân tốt nhất). Tall (3:4 on mobile; 3:5 on sm+) cells with condensed bold black numbers (`tan-tan-num` font stack w/ self-hosted Roboto Condensed), white number cells, purple empty cells (dark mode dims via `filter:brightness(0.85)`). Handles crossed state, animated cross-out (200 ms `cross-draw` keyframe), `active:scale-90` press, 10 ms haptic on tap. Two header actions: "Tạo bảng mới" / "Xoá đánh dấu". First-run state shows a faded preview card. Bingo popup tiers: row 1 = standard celebration; row 3+ = falling-emoji confetti rain via CSS `confetti-fall`. Toast "Chờ N" + audio. Accepts `storagePrefix` prop for multi-card isolation. |
| `src/lib/SettingsButton.svelte` | Gear icon + modal (responsive `max-w-sm sm:max-w-md`). 6 fieldsets: Giao diện (theme pills), Chế độ (3-way mode picker w/ SVG glyphs: player/master/both), Chế độ quản trò (switch row), Tự động xổ (switch + speed slider), Âm thanh (two switches + voice picker), Màu ô trống (10 Excel swatches + custom input in bordered card w/ "Tuỳ chỉnh"/"Mẫu" sub-headers). Boolean toggles use a shared `switchRow` snippet (`role="switch"` + keyboard support). Reset-to-default button. Mounted on `/`. |
| `src/lib/MasterEmptyState.svelte` | Empty board placeholder for first-run master (mirrors PlayerBoard's preview UX). Displays faded 11×9 grid with "Ấn để bắt đầu ván mới" hint. |
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, large "Số vừa xổ" hero token (160 px mobile, 224 px sm+) with `aria-live="assertive"` + auto `scrollIntoView` on each new draw, "Thứ tự đã xổ" history list, 11×9 last-digit-aligned tracking grid (with circular tokens + draw-order overlay). Publishes draws to `call-bus` for player auto-tick. "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.mode !== "player"`; the wrapping section uses `transition:slide` for smooth toggle-in. |
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, large "Số vừa xổ" hero token (160 px mobile, 224 px sm+) with `aria-live="assertive"` + auto `scrollIntoView` on each new draw, "Thứ tự đã xổ" history list, 11×9 last-digit-aligned tracking grid (with circular tokens + draw-order overlay). Publishes draws to `call-bus` for player auto-tick. "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. While auto-call runs, mounts `<AutoCountdown>` above the hero (driven by `tickCount` $state, bumped on draw and on every (re-)arm of the auto-call $effect). Mounted conditionally on `/` when `settings.mode !== "player"`; the wrapping section uses `transition:slide` for smooth toggle-in. |
| `src/lib/AutoCountdown.svelte` | Visual countdown for auto-call. Props-driven (`running`, `duration`, `tickKey`) — parent owns the `setInterval`, this component just renders. SVG ring with `stroke-dashoffset` controlled by elapsed-time progress (rAF loop while running) plus centered seconds-remaining number. `prefers-reduced-motion` clamps `dashOffset = 0` (static full ring). `role="timer"` + `aria-live="off"` so screen readers don't announce every second. |
| `src/lib/PageFooter.svelte` | Footer with tagline ("Made by miti99 with ❤️ SVG icon") + link. Mounted on `/`. |
### Game Logic & Coordination
+101
View File
@@ -0,0 +1,101 @@
<script>
/**
* Visual countdown for the master panel's auto-call.
* Props-driven: parent owns the timer, this component just renders.
* @module lib/AutoCountdown
*/
/**
* @typedef {Object} Props
* @property {boolean} running - master is currently auto-calling
* @property {number} duration - seconds per tick (1..10)
* @property {number} tickKey - bump to reset the ring
*/
/** @type {Props} */
let { running, duration, tickKey } = $props();
let tickStart = $state(performance.now());
let now = $state(performance.now());
// One-time read; reduce-motion users see a static ring + ticking number.
const reduceMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
// Re-baseline whenever the parent bumps tickKey, running flips on, or
// duration changes mid-run (e.g. host moves the speed slider).
$effect(() => {
tickKey; // subscribe
duration; // subscribe — keeps the contract explicit, not parent-coupled
if (running) {
tickStart = performance.now();
now = tickStart;
}
});
// rAF loop is the only writer of `now` while running. Cleanup cancels it
// on running=false / unmount, so no leaks across master mode toggles.
$effect(() => {
if (!running) return;
let raf = requestAnimationFrame(function loop() {
now = performance.now();
raf = requestAnimationFrame(loop);
});
return () => cancelAnimationFrame(raf);
});
const elapsedMs = $derived(Math.max(0, now - tickStart));
const totalMs = $derived(Math.max(1, duration * 1000));
const progress = $derived(Math.min(1, elapsedMs / totalMs));
// Clamp to [1, duration] while running — avoids flashing 0 between the
// interval edge and the parent's tickKey bump.
const secondsRemaining = $derived(
running ? Math.max(1, Math.ceil(duration - elapsedMs / 1000)) : duration,
);
const SIZE = 100;
const RADIUS = 44;
const STROKE = 8;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const dashOffset = $derived(
reduceMotion ? 0 : CIRCUMFERENCE * progress,
);
</script>
<div
class="relative w-20 h-20 sm:w-24 sm:h-24"
role="timer"
aria-live="off"
aria-label="Đếm ngược: {secondsRemaining} giây"
>
<svg viewBox="0 0 {SIZE} {SIZE}" class="w-full h-full -rotate-90">
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={RADIUS}
fill="none"
stroke="currentColor"
stroke-width={STROKE}
class="text-slate-200 dark:text-slate-700"
/>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={RADIUS}
fill="none"
stroke="currentColor"
stroke-width={STROKE}
stroke-linecap="round"
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={dashOffset}
class="text-amber-500 dark:text-amber-400"
/>
</svg>
<span
class="absolute inset-0 flex items-center justify-center
text-2xl sm:text-3xl font-black tabular-nums
text-slate-700 dark:text-slate-100"
>
{secondsRemaining}
</span>
</div>
+21
View File
@@ -79,6 +79,7 @@
</script>
<script>
import AutoCountdown from "$lib/AutoCountdown.svelte";
import { broadcastDraw, resetBus } from "$lib/call-bus.svelte.js";
import MasterEmptyState from "$lib/MasterEmptyState.svelte";
import { settings } from "$lib/settings-store.svelte.js";
@@ -90,6 +91,9 @@
let lastCalled = $state(/** @type {number | null} */ (null));
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
let autoRunning = $state(false);
// Bumped on each draw and on every (re-)arm of the auto-call interval —
// signals AutoCountdown to reset its ring.
let tickCount = $state(0);
$effect(() => {
const saved = loadState();
@@ -119,6 +123,12 @@
autoRunning = false;
return;
}
// Re-baseline countdown on every (re-)arm: rising edge of autoRunning,
// speed change, autoCallEnabled toggle. handleDrawNext bumps it again
// per tick so the ring re-fills before the next interval elapses.
// Safe self-write: this effect doesn't read tickCount, so the bump
// can't re-trigger it.
tickCount++;
const ms = settings.autoCallSpeed * 1000;
const id = setInterval(() => {
if (!state || state.remaining.length === 0) {
@@ -173,6 +183,7 @@
lastCalled = next;
scrollOnNextDraw = true;
broadcastDraw(next);
tickCount++;
if (settings.voiceEnabledMaster) playNumber(next);
}
@@ -225,6 +236,16 @@
</div>
{/if}
{#if autoRunning && state && state.remaining.length > 0}
<div class="flex justify-center mb-4">
<AutoCountdown
running={autoRunning}
duration={settings.autoCallSpeed}
tickKey={tickCount}
/>
</div>
{/if}
<!-- Current number -->
{#if lastCalled}
{@const lastIsLow = lastCalled <= 49}