mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-14 00:16:47 +00:00
180 lines
6.6 KiB
Markdown
180 lines
6.6 KiB
Markdown
# Lô Tô
|
||
|
||
Web version of *Lô tô hội chợ* — Vietnamese fairground bingo. Built with SvelteKit
|
||
(static export). The caller draws numbers one at a time; a host panel manages the
|
||
game and called numbers are announced aloud in Vietnamese using bundled audio clips.
|
||
No runtime TTS or server required.
|
||
|
||
Live: [tiennm99.github.io/loto](https://tiennm99.github.io/loto)
|
||
|
||
Android port: [`android/`](../android) — a native Kotlin + Jetpack Compose
|
||
app at feature parity with this one (this app is the behavioral spec). It
|
||
mounts `static/audio/` straight into its APK, so the voice clips exist once.
|
||
|
||
---
|
||
|
||
## Features
|
||
|
||
- **Number board** — 1–90 grid; called numbers highlight as they are drawn
|
||
- **Host panel** — enable "Chế độ quản trò" (host mode) in settings to reveal the
|
||
master draw panel inline below your player card
|
||
- **Vietnamese voice callouts** — pre-generated MP3 clips for every number using
|
||
Microsoft Edge TTS (`edge-tts`); multiple voices selectable in settings; no API
|
||
key or internet connection needed at runtime
|
||
- **Auto-cross player card** — optional automatic crossing of numbers on the player's
|
||
bingo card as they are called
|
||
- **Auto-countdown** — configurable delay between number draws for unattended calling
|
||
- **PWA** — installable as a progressive web app; works offline after first load
|
||
- **Static export** — no server; deploys to any static host or GitHub Pages
|
||
|
||
---
|
||
|
||
## Requirements
|
||
|
||
[Node.js](https://nodejs.org) 18+ and npm.
|
||
|
||
---
|
||
|
||
## Quick Start
|
||
|
||
```sh
|
||
git clone https://github.com/tiennm99/loto
|
||
cd loto
|
||
npm install
|
||
npm run dev # dev server at http://localhost:5173
|
||
```
|
||
|
||
---
|
||
|
||
## Commands
|
||
|
||
| Command | Description |
|
||
|---|---|
|
||
| `npm run dev` | Start development server |
|
||
| `npm run build` | Production build (root base path) |
|
||
| `npm run build:gh` | Production build for GitHub Pages (`/loto` base path) |
|
||
| `npm run preview` | Preview production build locally |
|
||
| `npm test` | Run unit tests |
|
||
| `npm run lint` | ESLint check |
|
||
| `npm run check` | svelte-check type check (JSDoc) |
|
||
| `npm run verify:pwa` | Check the service worker in `build/` (after a build, same `BUILD_PROFILE`) |
|
||
|
||
---
|
||
|
||
## Configuration
|
||
|
||
Copy `.env.example` to `.env.local` and set:
|
||
|
||
| Variable | Description |
|
||
|---|---|
|
||
| `CODESERVER_HOST` | Hostname for code-server reverse-proxy dev (optional) |
|
||
| `CODESERVER_PORT` | Port for code-server proxy (optional) |
|
||
|
||
When running inside code-server use `npm run dev:codeserver` and open
|
||
`https://<CODESERVER_HOST>/absproxy/<CODESERVER_PORT>/` (use `/absproxy/`, not
|
||
`/proxy/` — the latter strips the path prefix and breaks SvelteKit routing).
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
Single-page SvelteKit app, statically exported. No backend.
|
||
|
||
```
|
||
src/
|
||
├── routes/ # Single route: /
|
||
├── lib/
|
||
│ ├── game-logic.js # Draw state, number pool, called-number tracking
|
||
│ ├── master-store.svelte.js # Host panel state (Svelte 5 runes store)
|
||
│ ├── settings-store.svelte.js
|
||
│ ├── voice.js # Audio manifest loader + clip player
|
||
│ ├── player-auto-cross.js # Auto-cross logic
|
||
│ ├── MasterPanel.svelte # Host draw UI
|
||
│ ├── PlayerBoard.svelte # Bingo card + number grid
|
||
│ └── AutoCountdown.svelte # Countdown timer between draws
|
||
static/
|
||
└── audio/{voiceId}/ # Pre-generated MP3 clips (one per number per voice)
|
||
scripts/
|
||
└── generate-audio.py # Regenerates audio clips via edge-tts
|
||
```
|
||
|
||
Game state lives entirely in Svelte 5 rune stores; no server round-trips.
|
||
|
||
### Build config
|
||
|
||
- **No `svelte.config.js`.** SvelteKit 3 takes its options (adapter-static,
|
||
`paths.base` from `base-path.js`) as arguments to the `sveltekit()` plugin
|
||
in `vite.config.js`.
|
||
- **`#lib/*` imports.** `src/lib` is reached through the `imports` field in
|
||
`package.json` (`import { settings } from "#lib/settings-store.svelte.js"`),
|
||
always with the file extension. `jsconfig.json` extends `$app/tsconfig`.
|
||
- **Base-path-safe URLs.** Static files go through `asset()` from
|
||
`$app/paths` (no leading `/`: `asset("audio/…")`), so they resolve under
|
||
both `""` and `/loto`.
|
||
|
||
### PWA
|
||
|
||
The service worker comes from
|
||
[vite-plugin-pwa](https://vite-pwa-org.netlify.app/) (`generateSW`,
|
||
`registerType: "prompt"`); the manifest is the hand-written
|
||
`static/manifest.webmanifest`. `+layout.svelte` registers it through
|
||
`virtual:pwa-register` and wires `onNeedRefresh` to the update banner in
|
||
`src/lib/update-prompt.svelte.js`. It precaches the app shell plus the default
|
||
voice; other voices are cached on first play.
|
||
|
||
`@vite-pwa/sveltekit` is not used: it generates `sw.js` when Vite's SSR build
|
||
closes, which assumed SvelteKit 2's build order. SvelteKit 3 builds SSR first,
|
||
then the client, then prerenders, all inside its `buildApp` hook, so
|
||
`vite.config.js` drops vite-plugin-pwa's own build step and calls its
|
||
`generateSW()` from a `buildApp` hook that runs after SvelteKit's and before
|
||
the adapter copies `.svelte-kit/output` into `build/`.
|
||
`npm run verify:pwa` checks the result after a build (run it with the same
|
||
`BUILD_PROFILE`).
|
||
Audio manifest is generated once at build prep time — the app reads a JSON manifest
|
||
on load and plays the appropriate clip for each drawn number.
|
||
|
||
---
|
||
|
||
## Regenerating Audio
|
||
|
||
Voice clips in `static/audio/` were generated with
|
||
[edge-tts](https://github.com/rany2/edge-tts) (free, no API key). To regenerate
|
||
(e.g. to add a new Microsoft `vi-*` voice):
|
||
|
||
```sh
|
||
pip install edge-tts
|
||
python3 scripts/generate-audio.py
|
||
```
|
||
|
||
The script auto-discovers all available `vi-*` voices and writes an updated manifest.
|
||
|
||
---
|
||
|
||
## Regenerating Icons
|
||
|
||
`static/icons/source.svg` is the source for `icon-192.png` and `icon-512.png`;
|
||
`static/icons/source-maskable.svg` (full-bleed background, wordmark inside the
|
||
80% safe zone) is the source for `icon-maskable-512.png`. Render them with the
|
||
real **Roboto Condensed Black Italic** TTF loaded explicitly — a fallback font is
|
||
wider and clips the wordmark. Any SVG rasterizer that accepts a font file works,
|
||
e.g. [`@resvg/resvg-js`](https://github.com/thx/resvg-js) with
|
||
`font: { fontFiles: [...], loadSystemFonts: false }` and `fitTo` set to each
|
||
PNG's exact pixel width. Open the results and check nothing touches the edges.
|
||
|
||
---
|
||
|
||
## Deployment
|
||
|
||
Deployed to GitHub Pages by the `deploy-pages` job in
|
||
`.github/workflows/ci.yml` on push to `main`. Build command: `npm run build:gh`
|
||
(sets `/loto` base path). Static output in `build/`.
|
||
|
||
The same workflow also deploys the base-`""` build to Firebase Hosting and
|
||
bundles it into the debug APK, so `web/` is built once per base path per run.
|
||
|
||
---
|
||
|
||
## License
|
||
|
||
Apache-2.0 — see [`LICENSE`](../LICENSE) at the repository root.
|