mirror of
https://github.com/tiennm99/loto.git
synced 2026-09-10 06:20:44 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user