Prod hardening: one-game-per-player, selectable music, bargevasat.ir config
- One running game per player: server rejects a 2nd matchmake while in a live room (re-syncs the existing game); client guards Home vs-computer + Lobby random/create — resumes the running match + notifies instead of starting another (game-store hasActiveMatch()). - Background music is now selectable: santoor (سنتی, calm Persian loop) and playful (bouncy UNO-like) — sound.ts TRACKS + setMusicTrack (persisted), sound-store musicTrack, picker in Profile → Audio. i18n added. - Production config for bargevasat.ir (prepare-only; no live deploy): appsettings.Production.example (CORS + ZarinPal + IAB to the domain), docker-compose.caddy.yml + Caddyfile (auto-HTTPS reverse proxy bargevasat.ir→web, api.bargevasat.ir→server), ENV_FILE PRODUCTION block, PRODUCTION.md go-live + Cafe Bazaar publish/IAB checklist. Fixed IAB package name to match Capacitor appId (com.bargevasat.app). Verified: tsc + next build + dotnet build all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# Caddy reverse proxy for production (bargevasat.ir). Auto HTTPS via Let's Encrypt.
|
||||||
|
bargevasat.ir, www.bargevasat.ir {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy web:80
|
||||||
|
}
|
||||||
|
|
||||||
|
api.bargevasat.ir {
|
||||||
|
encode zstd gzip
|
||||||
|
# SignalR (WebSockets) proxies transparently through Caddy.
|
||||||
|
reverse_proxy server:5005
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Production go-live — bargevasat.ir + Cafe Bazaar
|
||||||
|
|
||||||
|
Companion to `HANDOFF.md` / `DEPLOY.md`. Domain: **bargevasat.ir** (web) +
|
||||||
|
**api.bargevasat.ir** (.NET SignalR API). Android via **Cafe Bazaar**.
|
||||||
|
|
||||||
|
## 1. DNS + firewall (you do this)
|
||||||
|
- A-records → your server IP: `bargevasat.ir`, `www.bargevasat.ir`, `api.bargevasat.ir`.
|
||||||
|
- Open ports **80** + **443** (`ufw allow 80 && ufw allow 443`).
|
||||||
|
|
||||||
|
## 2. Production env (Gitea `ENV_FILE` secret)
|
||||||
|
Use the **PRODUCTION block** in `deploy/ENV_FILE.example`:
|
||||||
|
- `NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir` (baked at web build → needs a CI rebuild to change)
|
||||||
|
- `CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir`
|
||||||
|
- `JWT_KEY` = `openssl rand -hex 32`, strong `POSTGRES_PASSWORD`
|
||||||
|
- ZarinPal **live**: `ZARINPAL_SANDBOX=false`, live merchant id, callback `https://api.bargevasat.ir/api/coins/pay/callback`, return `https://bargevasat.ir`
|
||||||
|
- ZarinPal panel: register the callback domain.
|
||||||
|
|
||||||
|
## 3. Deploy with HTTPS (Caddy)
|
||||||
|
The deploy job (or you, on the server) runs the stack **with the Caddy overlay**:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
|
||||||
|
```
|
||||||
|
Caddy auto-provisions Let's Encrypt certs and proxies `bargevasat.ir → web`,
|
||||||
|
`api.bargevasat.ir → server`. SignalR WebSockets pass through transparently.
|
||||||
|
(To wire this into CI, add `-f docker-compose.caddy.yml` to the deploy job's
|
||||||
|
compose commands once DNS resolves.)
|
||||||
|
|
||||||
|
## 4. Database (Supabase or the bundled Postgres)
|
||||||
|
- Bundled `db` service works for launch. For Supabase: set `Database__Provider=postgres`
|
||||||
|
+ the Supabase `ConnectionStrings__Default`, and **generate EF migrations** first
|
||||||
|
(`HANDOFF.md` §5.1) so the server runs `Migrate()` instead of `EnsureCreated()`.
|
||||||
|
- **Back up before every deploy** (the deploy job already `pg_dump`s).
|
||||||
|
|
||||||
|
## 5. Cafe Bazaar (Android) publish
|
||||||
|
1. **Build a signed release APK/AAB** — `NEXT_PUBLIC_STORE=bazaar`,
|
||||||
|
`NEXT_PUBLIC_APP_PACKAGE=com.bargevasat.app`, `NEXT_PUBLIC_USE_SERVER=1`,
|
||||||
|
`NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir`, then `npm run cap:sync` +
|
||||||
|
build in Android Studio / gradle (see `ANDROID.md`). App id **com.bargevasat.app**.
|
||||||
|
2. Upload to **pardakht.cafebazaar.ir**, fill the listing (icon, screenshots, fa
|
||||||
|
description), submit for review.
|
||||||
|
3. **In-app billing (after approval):** in the Bazaar dev panel create the coin
|
||||||
|
SKUs (`p1`–`p4`, matching `ProfileService.Packs`), create the **Pardakht API**
|
||||||
|
OAuth client, do the one-time consent to get a **refresh token**, and put
|
||||||
|
`IAB_BAZAAR_CLIENT_ID/SECRET/REFRESH_TOKEN` (+ `IAB_PACKAGE_NAME=com.bargevasat.app`)
|
||||||
|
into `ENV_FILE`; set `IAB_ALLOW_UNVERIFIED=false`. The web client deep-links
|
||||||
|
`bazaar://in_app?...` and the server verifies the returned `purchaseToken`
|
||||||
|
before crediting (see `HANDOFF.md` §5.3 / `src/lib/storeBilling.ts`).
|
||||||
|
4. Set `Zarinpal__Sandbox=false` only for the **web/PWA** payment path; the
|
||||||
|
**store build uses IAB, not ZarinPal** (store policy).
|
||||||
|
|
||||||
|
## 6. Pre-launch hardening checklist
|
||||||
|
- [ ] `JWT_KEY` is a real 32+ char secret (compose `${JWT_KEY:?}` fails if unset).
|
||||||
|
- [ ] `IAB_ALLOW_UNVERIFIED=false`, `ZARINPAL_SANDBOX=false`.
|
||||||
|
- [ ] CORS = the real domains only (no localhost).
|
||||||
|
- [ ] DB backups confirmed (`/opt/hokm-backups`), volumes named (no orphan data — see DEPLOY.md incident rules).
|
||||||
|
- [ ] CI green: tsc + next build + dotnet build + Hokm.Sim.
|
||||||
|
- [ ] Smoke test on https://bargevasat.ir: OTP login, vs-AI game, ranked match, buy-coins redirect, friends/chat.
|
||||||
+19
-1
@@ -44,7 +44,7 @@ ZARINPAL_CLIENT_RETURN_URL=http://localhost:1500
|
|||||||
# Store in-app billing (Cafe Bazaar / Myket) — fill from the developer panels.
|
# Store in-app billing (Cafe Bazaar / Myket) — fill from the developer panels.
|
||||||
# SKU == coin-pack id (p1/p2/…). Coins are credited only after the purchase
|
# SKU == coin-pack id (p1/p2/…). Coins are credited only after the purchase
|
||||||
# token verifies server-to-server.
|
# token verifies server-to-server.
|
||||||
IAB_PACKAGE_NAME=com.bargevasat.hokm
|
IAB_PACKAGE_NAME=com.bargevasat.app
|
||||||
# Cafe Bazaar (pardakht dev API): create an OAuth client, do the one-time consent
|
# Cafe Bazaar (pardakht dev API): create an OAuth client, do the one-time consent
|
||||||
# to obtain a refresh_token. https://pardakht.cafebazaar.ir/
|
# to obtain a refresh_token. https://pardakht.cafebazaar.ir/
|
||||||
IAB_BAZAAR_CLIENT_ID=
|
IAB_BAZAAR_CLIENT_ID=
|
||||||
@@ -55,3 +55,21 @@ IAB_MYKET_ACCESS_TOKEN=
|
|||||||
# DEV ONLY: credit purchases WITHOUT verifying (set true to test before you have
|
# DEV ONLY: credit purchases WITHOUT verifying (set true to test before you have
|
||||||
# store creds). NEVER true in production.
|
# store creds). NEVER true in production.
|
||||||
IAB_ALLOW_UNVERIFIED=false
|
IAB_ALLOW_UNVERIFIED=false
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# PRODUCTION (bargevasat.ir) — use these values instead of the local ones above,
|
||||||
|
# and deploy with the Caddy overlay (see PRODUCTION.md). DNS: bargevasat.ir,
|
||||||
|
# www, api → server IP; open 80/443. Caddy fronts TLS, so host ports are internal.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# WEB_PORT=1500
|
||||||
|
# API_PORT=1505
|
||||||
|
# DB_PORT=1510
|
||||||
|
# POSTGRES_PASSWORD=<strong>
|
||||||
|
# JWT_KEY=<openssl rand -hex 32>
|
||||||
|
# NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir # baked at web build time
|
||||||
|
# CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir
|
||||||
|
# ZARINPAL_MERCHANT_ID=<live-merchant-id>
|
||||||
|
# ZARINPAL_SANDBOX=false
|
||||||
|
# ZARINPAL_CALLBACK_URL=https://api.bargevasat.ir/api/coins/pay/callback
|
||||||
|
# ZARINPAL_CLIENT_RETURN_URL=https://bargevasat.ir
|
||||||
|
# IAB_ALLOW_UNVERIFIED=false # fill the IAB_* creds from the Bazaar panel post-publish
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Production HTTPS overlay for bargevasat.ir.
|
||||||
|
# Caddy terminates TLS (auto Let's Encrypt) and reverse-proxies:
|
||||||
|
# https://bargevasat.ir → web (nginx static)
|
||||||
|
# https://api.bargevasat.ir → server (.NET SignalR)
|
||||||
|
# Run: docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
|
||||||
|
# (web/server are reached over the compose network by name; their host port
|
||||||
|
# publishes from docker-compose.yml are harmless but optional in prod.)
|
||||||
|
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: mirror.soroushasadi.com/caddy:2-alpine
|
||||||
|
container_name: hokm-caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
- server
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- hokm_caddy_data:/data
|
||||||
|
- hokm_caddy_config:/config
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
hokm_caddy_data:
|
||||||
|
hokm_caddy_config:
|
||||||
@@ -47,6 +47,14 @@ public sealed class GameManager
|
|||||||
|
|
||||||
public void StartMatchmaking(Player p)
|
public void StartMatchmaking(Player p)
|
||||||
{
|
{
|
||||||
|
// One running game per player: if already in a live match, re-sync them to
|
||||||
|
// it (re-broadcasts current state) instead of starting a second game.
|
||||||
|
if (RoomOf(p.UserId) is { } existing)
|
||||||
|
{
|
||||||
|
existing.SetConnected(p.UserId, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Pro players skip the queue entirely.
|
// Pro players skip the queue entirely.
|
||||||
if (p.Plan == "pro")
|
if (p.Plan == "pro")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production.",
|
"// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production. (The Docker deploy uses env vars from ENV_FILE instead — this file is for a bare-metal run.)",
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Key": "CHANGE-ME-to-a-long-random-secret-32+chars",
|
"Key": "CHANGE-ME-to-a-long-random-secret-32+chars",
|
||||||
"Issuer": "hokm",
|
"Issuer": "hokm",
|
||||||
@@ -12,10 +12,27 @@
|
|||||||
"// Supabase": "Project Settings → Database → Connection string (use the pooled 6543 or direct 5432)",
|
"// Supabase": "Project Settings → Database → Connection string (use the pooled 6543 or direct 5432)",
|
||||||
"Default": "Host=db.<project>.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=<password>;SSL Mode=Require;Trust Server Certificate=true"
|
"Default": "Host=db.<project>.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=<password>;SSL Mode=Require;Trust Server Certificate=true"
|
||||||
},
|
},
|
||||||
|
"Cors": {
|
||||||
|
"Origins": "https://bargevasat.ir,https://www.bargevasat.ir"
|
||||||
|
},
|
||||||
"Zarinpal": {
|
"Zarinpal": {
|
||||||
"MerchantId": "<your-live-merchant-id>",
|
"MerchantId": "<your-live-merchant-id>",
|
||||||
"Sandbox": false,
|
"Sandbox": false,
|
||||||
"CallbackUrl": "https://api.yourdomain.com/api/coins/pay/callback",
|
"CallbackUrl": "https://api.bargevasat.ir/api/coins/pay/callback",
|
||||||
"ClientReturnUrl": "https://yourdomain.com"
|
"ClientReturnUrl": "https://bargevasat.ir"
|
||||||
|
},
|
||||||
|
"Iab": {
|
||||||
|
"// note": "Cafe Bazaar / Myket in-app purchase. Fill after publishing & getting store creds.",
|
||||||
|
"AllowUnverified": false,
|
||||||
|
"Bazaar": {
|
||||||
|
"PackageName": "com.bargevasat.app",
|
||||||
|
"ClientId": "<bazaar-client-id>",
|
||||||
|
"ClientSecret": "<bazaar-client-secret>",
|
||||||
|
"RefreshToken": "<bazaar-refresh-token>"
|
||||||
|
},
|
||||||
|
"Myket": {
|
||||||
|
"PackageName": "com.bargevasat.app",
|
||||||
|
"AccessToken": "<myket-access-token>"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Zap } from "lucide-react";
|
import { Zap } from "lucide-react";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore, hasActiveMatch } from "@/lib/game-store";
|
||||||
|
import { pushNotification } from "@/lib/notification-store";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useUIStore, type Screen } from "@/lib/ui-store";
|
import { useUIStore, type Screen } from "@/lib/ui-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
@@ -45,6 +46,20 @@ export function HomeScreen() {
|
|||||||
const [speed, setSpeed] = useState(false);
|
const [speed, setSpeed] = useState(false);
|
||||||
|
|
||||||
const playVsComputer = () => {
|
const playVsComputer = () => {
|
||||||
|
// One game at a time: resume the running match instead of starting a new one.
|
||||||
|
if (hasActiveMatch()) {
|
||||||
|
useGameStore.getState().resume();
|
||||||
|
goGame("home");
|
||||||
|
pushNotification({
|
||||||
|
kind: "system",
|
||||||
|
titleFa: "بازی در جریان",
|
||||||
|
titleEn: "Game in progress",
|
||||||
|
bodyFa: "ابتدا بازی فعلی را تمام کنید یا تسلیم شوید.",
|
||||||
|
bodyEn: "Finish or forfeit your current game first.",
|
||||||
|
icon: "🎮",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const you = profile?.displayName || t("seat.you");
|
const you = profile?.displayName || t("seat.you");
|
||||||
newMatch({
|
newMatch({
|
||||||
names: [you, "آرش", "کیان", "نیلوفر"],
|
names: [you, "آرش", "کیان", "نیلوفر"],
|
||||||
|
|||||||
@@ -9,9 +9,27 @@ import { MATCH_LEAGUES, leagueById } from "@/lib/online/gamification";
|
|||||||
import { useOnlineStore } from "@/lib/online-store";
|
import { useOnlineStore } from "@/lib/online-store";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
|
import { useGameStore, hasActiveMatch } from "@/lib/game-store";
|
||||||
|
import { pushNotification } from "@/lib/notification-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
/** Block starting a 2nd game while one is running — resume it instead. */
|
||||||
|
function guardActiveMatch(): boolean {
|
||||||
|
if (!hasActiveMatch()) return false;
|
||||||
|
useGameStore.getState().resume();
|
||||||
|
useUIStore.getState().goGame("online");
|
||||||
|
pushNotification({
|
||||||
|
kind: "system",
|
||||||
|
titleFa: "بازی در جریان",
|
||||||
|
titleEn: "Game in progress",
|
||||||
|
bodyFa: "ابتدا بازی فعلی را تمام کنید یا تسلیم شوید.",
|
||||||
|
bodyEn: "Finish or forfeit your current game first.",
|
||||||
|
icon: "🎮",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function OnlineLobbyScreen() {
|
export function OnlineLobbyScreen() {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const createRoom = useOnlineStore((s) => s.createRoom);
|
const createRoom = useOnlineStore((s) => s.createRoom);
|
||||||
@@ -27,12 +45,14 @@ export function OnlineLobbyScreen() {
|
|||||||
|
|
||||||
// Private rooms with friends are free.
|
// Private rooms with friends are free.
|
||||||
const onCreate = async () => {
|
const onCreate = async () => {
|
||||||
|
if (guardActiveMatch()) return;
|
||||||
await createRoom({ targetScore: 7, stake: 0, ranked: false });
|
await createRoom({ targetScore: 7, stake: 0, ranked: false });
|
||||||
go("room");
|
go("room");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ranked random always costs the entry (you stake it).
|
// Ranked random always costs the entry (you stake it).
|
||||||
const onRandom = async () => {
|
const onRandom = async () => {
|
||||||
|
if (guardActiveMatch()) return;
|
||||||
if (lockedLeague) return;
|
if (lockedLeague) return;
|
||||||
if (coins < entry) {
|
if (coins < entry) {
|
||||||
go("buycoins");
|
go("buycoins");
|
||||||
|
|||||||
@@ -469,12 +469,34 @@ function SocialSettings() {
|
|||||||
|
|
||||||
function SoundSettings() {
|
function SoundSettings() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { sfx, music, toggleSfx, toggleMusic } = useSoundStore();
|
const { sfx, music, musicTrack, toggleSfx, toggleMusic, setMusicTrack } = useSoundStore();
|
||||||
|
const tracks = [
|
||||||
|
{ id: "santoor" as const, label: t("settings.trackSantoor") },
|
||||||
|
{ id: "playful" as const, label: t("settings.trackPlayful") },
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-2xl p-4 mt-4">
|
<div className="glass rounded-2xl p-4 mt-4">
|
||||||
<h3 className="text-sm font-bold text-cream/80 mb-2">{t("settings.audio")}</h3>
|
<h3 className="text-sm font-bold text-cream/80 mb-2">{t("settings.audio")}</h3>
|
||||||
<ToggleRow icon={<Volume2 className="size-4 text-gold-400" />} label={t("settings.sound")} on={sfx} onClick={toggleSfx} />
|
<ToggleRow icon={<Volume2 className="size-4 text-gold-400" />} label={t("settings.sound")} on={sfx} onClick={toggleSfx} />
|
||||||
<ToggleRow icon={<Music className="size-4 text-gold-400" />} label={t("settings.music")} on={music} onClick={toggleMusic} />
|
<ToggleRow icon={<Music className="size-4 text-gold-400" />} label={t("settings.music")} on={music} onClick={toggleMusic} />
|
||||||
|
{/* music style picker */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-[11px] text-cream/55 mb-1.5">{t("settings.musicStyle")}</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{tracks.map((tr) => (
|
||||||
|
<button
|
||||||
|
key={tr.id}
|
||||||
|
onClick={() => setMusicTrack(tr.id)}
|
||||||
|
className={cn(
|
||||||
|
"press-3d rounded-xl py-2.5 text-sm font-bold",
|
||||||
|
musicTrack === tr.id ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tr.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -572,3 +572,12 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the player has a running match that hasn't finished — used to enforce
|
||||||
|
* "one game at a time": entry points should resume this instead of starting another.
|
||||||
|
*/
|
||||||
|
export function hasActiveMatch(): boolean {
|
||||||
|
const s = useGameStore.getState();
|
||||||
|
return s.started && s.game.phase !== "match-over";
|
||||||
|
}
|
||||||
|
|||||||
@@ -321,6 +321,9 @@ const fa: Dict = {
|
|||||||
"settings.audio": "تنظیمات صدا",
|
"settings.audio": "تنظیمات صدا",
|
||||||
"settings.sound": "افکت صدا",
|
"settings.sound": "افکت صدا",
|
||||||
"settings.music": "موسیقی پسزمینه",
|
"settings.music": "موسیقی پسزمینه",
|
||||||
|
"settings.musicStyle": "سبک موسیقی",
|
||||||
|
"settings.trackSantoor": "سنتی (سنتور)",
|
||||||
|
"settings.trackPlayful": "شاد",
|
||||||
|
|
||||||
"profile.cardFront": "روی کارت",
|
"profile.cardFront": "روی کارت",
|
||||||
"profile.cardBack": "پشت کارت",
|
"profile.cardBack": "پشت کارت",
|
||||||
@@ -648,6 +651,9 @@ const en: Dict = {
|
|||||||
"settings.audio": "Audio",
|
"settings.audio": "Audio",
|
||||||
"settings.sound": "Sound effects",
|
"settings.sound": "Sound effects",
|
||||||
"settings.music": "Background music",
|
"settings.music": "Background music",
|
||||||
|
"settings.musicStyle": "Music style",
|
||||||
|
"settings.trackSantoor": "Traditional (Santoor)",
|
||||||
|
"settings.trackPlayful": "Playful",
|
||||||
|
|
||||||
"profile.cardFront": "Card front",
|
"profile.cardFront": "Card front",
|
||||||
"profile.cardBack": "Card back",
|
"profile.cardBack": "Card back",
|
||||||
|
|||||||
+10
-1
@@ -1,13 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { sound } from "./sound";
|
import { sound, type MusicTrack } from "./sound";
|
||||||
|
|
||||||
interface SoundStore {
|
interface SoundStore {
|
||||||
sfx: boolean;
|
sfx: boolean;
|
||||||
music: boolean;
|
music: boolean;
|
||||||
|
musicTrack: MusicTrack;
|
||||||
toggleSfx: () => void;
|
toggleSfx: () => void;
|
||||||
toggleMusic: () => void;
|
toggleMusic: () => void;
|
||||||
|
setMusicTrack: (t: MusicTrack) => void;
|
||||||
/** Master mute: turns BOTH sfx and music off (or both back on). */
|
/** Master mute: turns BOTH sfx and music off (or both back on). */
|
||||||
toggleAll: () => void;
|
toggleAll: () => void;
|
||||||
}
|
}
|
||||||
@@ -15,6 +17,13 @@ interface SoundStore {
|
|||||||
export const useSoundStore = create<SoundStore>((set, get) => ({
|
export const useSoundStore = create<SoundStore>((set, get) => ({
|
||||||
sfx: sound.sfxEnabled,
|
sfx: sound.sfxEnabled,
|
||||||
music: sound.musicEnabled,
|
music: sound.musicEnabled,
|
||||||
|
musicTrack: sound.musicTrack,
|
||||||
|
setMusicTrack: (t) => {
|
||||||
|
// Picking a track also turns music on so the choice is audible immediately.
|
||||||
|
sound.setMusicTrack(t);
|
||||||
|
sound.setMusicEnabled(true);
|
||||||
|
set({ musicTrack: t, music: true });
|
||||||
|
},
|
||||||
toggleSfx: () => {
|
toggleSfx: () => {
|
||||||
const v = !get().sfx;
|
const v = !get().sfx;
|
||||||
sound.setSfxEnabled(v);
|
sound.setSfxEnabled(v);
|
||||||
|
|||||||
+42
-10
@@ -18,6 +18,9 @@ export type Sfx =
|
|||||||
|
|
||||||
const LS_SFX = "hokm.sfx";
|
const LS_SFX = "hokm.sfx";
|
||||||
const LS_MUSIC = "hokm.music";
|
const LS_MUSIC = "hokm.music";
|
||||||
|
const LS_TRACK = "hokm.musicTrack";
|
||||||
|
|
||||||
|
export type MusicTrack = "santoor" | "playful";
|
||||||
|
|
||||||
function loadBool(key: string, def = true): boolean {
|
function loadBool(key: string, def = true): boolean {
|
||||||
if (typeof window === "undefined") return def;
|
if (typeof window === "undefined") return def;
|
||||||
@@ -34,6 +37,8 @@ class SoundManager {
|
|||||||
|
|
||||||
sfxEnabled = loadBool(LS_SFX);
|
sfxEnabled = loadBool(LS_SFX);
|
||||||
musicEnabled = loadBool(LS_MUSIC);
|
musicEnabled = loadBool(LS_MUSIC);
|
||||||
|
musicTrack: MusicTrack =
|
||||||
|
(typeof window !== "undefined" && (localStorage.getItem(LS_TRACK) as MusicTrack)) || "santoor";
|
||||||
|
|
||||||
/** Must be called from a user gesture to unlock audio. */
|
/** Must be called from a user gesture to unlock audio. */
|
||||||
init() {
|
init() {
|
||||||
@@ -70,6 +75,18 @@ class SoundManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Switch the background music style; restarts the loop if playing. */
|
||||||
|
setMusicTrack(track: MusicTrack) {
|
||||||
|
this.musicTrack = track;
|
||||||
|
if (typeof window !== "undefined") localStorage.setItem(LS_TRACK, track);
|
||||||
|
this.step = 0;
|
||||||
|
if (this.musicEnabled) {
|
||||||
|
this.stopMusic();
|
||||||
|
this.init();
|
||||||
|
this.startMusic();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private tone(
|
private tone(
|
||||||
freq: number,
|
freq: number,
|
||||||
start: number,
|
start: number,
|
||||||
@@ -147,29 +164,44 @@ class SoundManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gentle ambient loop on a Persian-flavored scale (Dastgah-ish).
|
// Two selectable loops:
|
||||||
private MUSIC = [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66];
|
// • santoor — calm Persian-flavored (Dastgah-ish) legato loop with fifth harmony.
|
||||||
|
// • playful — bouncy major-pentatonic staccato loop (UNO-like).
|
||||||
|
private TRACKS: Record<
|
||||||
|
MusicTrack,
|
||||||
|
{ notes: number[]; gap: number; type: OscillatorType; attack: number; dur: number; peak: number; fifth: boolean }
|
||||||
|
> = {
|
||||||
|
santoor: {
|
||||||
|
notes: [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66],
|
||||||
|
gap: 900, type: "sine", attack: 0.3, dur: 1.6, peak: 0.5, fifth: true,
|
||||||
|
},
|
||||||
|
playful: {
|
||||||
|
notes: [523.25, 659.25, 784, 659.25, 587.33, 698.46, 880, 698.46, 587.33, 523.25],
|
||||||
|
gap: 360, type: "triangle", attack: 0.02, dur: 0.34, peak: 0.4, fifth: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
startMusic() {
|
startMusic() {
|
||||||
if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return;
|
if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return;
|
||||||
const playNote = () => {
|
const playNote = () => {
|
||||||
if (!this.ctx || !this.musicGain) return;
|
if (!this.ctx || !this.musicGain) return;
|
||||||
const freq = this.MUSIC[this.step % this.MUSIC.length];
|
const cfg = this.TRACKS[this.musicTrack];
|
||||||
|
const freq = cfg.notes[this.step % cfg.notes.length];
|
||||||
this.step++;
|
this.step++;
|
||||||
const osc = this.ctx.createOscillator();
|
const osc = this.ctx.createOscillator();
|
||||||
const g = this.ctx.createGain();
|
const g = this.ctx.createGain();
|
||||||
const t = this.ctx.currentTime;
|
const t = this.ctx.currentTime;
|
||||||
osc.type = "sine";
|
osc.type = cfg.type;
|
||||||
osc.frequency.value = freq;
|
osc.frequency.value = freq;
|
||||||
g.gain.setValueAtTime(0.0001, t);
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
g.gain.exponentialRampToValueAtTime(0.5, t + 0.3);
|
g.gain.exponentialRampToValueAtTime(cfg.peak, t + cfg.attack);
|
||||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6);
|
g.gain.exponentialRampToValueAtTime(0.0001, t + cfg.dur);
|
||||||
osc.connect(g);
|
osc.connect(g);
|
||||||
g.connect(this.musicGain);
|
g.connect(this.musicGain);
|
||||||
osc.start(t);
|
osc.start(t);
|
||||||
osc.stop(t + 1.7);
|
osc.stop(t + cfg.dur + 0.1);
|
||||||
// soft fifth harmony every other note
|
// soft fifth harmony (santoor) every other note
|
||||||
if (this.step % 2 === 0) {
|
if (cfg.fifth && this.step % 2 === 0) {
|
||||||
const o2 = this.ctx.createOscillator();
|
const o2 = this.ctx.createOscillator();
|
||||||
const g2 = this.ctx.createGain();
|
const g2 = this.ctx.createGain();
|
||||||
o2.type = "sine";
|
o2.type = "sine";
|
||||||
@@ -184,7 +216,7 @@ class SoundManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
playNote();
|
playNote();
|
||||||
this.musicTimer = setInterval(playNote, 900);
|
this.musicTimer = setInterval(playNote, this.TRACKS[this.musicTrack].gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
stopMusic() {
|
stopMusic() {
|
||||||
|
|||||||
Reference in New Issue
Block a user