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.
|
||||
# SKU == coin-pack id (p1/p2/…). Coins are credited only after the purchase
|
||||
# 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
|
||||
# to obtain a refresh_token. https://pardakht.cafebazaar.ir/
|
||||
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
|
||||
# store creds). NEVER true in production.
|
||||
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)
|
||||
{
|
||||
// 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.
|
||||
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": {
|
||||
"Key": "CHANGE-ME-to-a-long-random-secret-32+chars",
|
||||
"Issuer": "hokm",
|
||||
@@ -12,10 +12,27 @@
|
||||
"// 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"
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": "https://bargevasat.ir,https://www.bargevasat.ir"
|
||||
},
|
||||
"Zarinpal": {
|
||||
"MerchantId": "<your-live-merchant-id>",
|
||||
"Sandbox": false,
|
||||
"CallbackUrl": "https://api.yourdomain.com/api/coins/pay/callback",
|
||||
"ClientReturnUrl": "https://yourdomain.com"
|
||||
"CallbackUrl": "https://api.bargevasat.ir/api/coins/pay/callback",
|
||||
"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";
|
||||
import { useEffect, useState } from "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 { useUIStore, type Screen } from "@/lib/ui-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
@@ -45,6 +46,20 @@ export function HomeScreen() {
|
||||
const [speed, setSpeed] = useState(false);
|
||||
|
||||
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");
|
||||
newMatch({
|
||||
names: [you, "آرش", "کیان", "نیلوفر"],
|
||||
|
||||
@@ -9,9 +9,27 @@ import { MATCH_LEAGUES, leagueById } from "@/lib/online/gamification";
|
||||
import { useOnlineStore } from "@/lib/online-store";
|
||||
import { useSessionStore } from "@/lib/session-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 { 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() {
|
||||
const { t, locale } = useI18n();
|
||||
const createRoom = useOnlineStore((s) => s.createRoom);
|
||||
@@ -27,12 +45,14 @@ export function OnlineLobbyScreen() {
|
||||
|
||||
// Private rooms with friends are free.
|
||||
const onCreate = async () => {
|
||||
if (guardActiveMatch()) return;
|
||||
await createRoom({ targetScore: 7, stake: 0, ranked: false });
|
||||
go("room");
|
||||
};
|
||||
|
||||
// Ranked random always costs the entry (you stake it).
|
||||
const onRandom = async () => {
|
||||
if (guardActiveMatch()) return;
|
||||
if (lockedLeague) return;
|
||||
if (coins < entry) {
|
||||
go("buycoins");
|
||||
|
||||
@@ -469,12 +469,34 @@ function SocialSettings() {
|
||||
|
||||
function SoundSettings() {
|
||||
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 (
|
||||
<div className="glass rounded-2xl p-4 mt-4">
|
||||
<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={<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.sound": "افکت صدا",
|
||||
"settings.music": "موسیقی پسزمینه",
|
||||
"settings.musicStyle": "سبک موسیقی",
|
||||
"settings.trackSantoor": "سنتی (سنتور)",
|
||||
"settings.trackPlayful": "شاد",
|
||||
|
||||
"profile.cardFront": "روی کارت",
|
||||
"profile.cardBack": "پشت کارت",
|
||||
@@ -648,6 +651,9 @@ const en: Dict = {
|
||||
"settings.audio": "Audio",
|
||||
"settings.sound": "Sound effects",
|
||||
"settings.music": "Background music",
|
||||
"settings.musicStyle": "Music style",
|
||||
"settings.trackSantoor": "Traditional (Santoor)",
|
||||
"settings.trackPlayful": "Playful",
|
||||
|
||||
"profile.cardFront": "Card front",
|
||||
"profile.cardBack": "Card back",
|
||||
|
||||
+10
-1
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { sound } from "./sound";
|
||||
import { sound, type MusicTrack } from "./sound";
|
||||
|
||||
interface SoundStore {
|
||||
sfx: boolean;
|
||||
music: boolean;
|
||||
musicTrack: MusicTrack;
|
||||
toggleSfx: () => void;
|
||||
toggleMusic: () => void;
|
||||
setMusicTrack: (t: MusicTrack) => void;
|
||||
/** Master mute: turns BOTH sfx and music off (or both back on). */
|
||||
toggleAll: () => void;
|
||||
}
|
||||
@@ -15,6 +17,13 @@ interface SoundStore {
|
||||
export const useSoundStore = create<SoundStore>((set, get) => ({
|
||||
sfx: sound.sfxEnabled,
|
||||
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: () => {
|
||||
const v = !get().sfx;
|
||||
sound.setSfxEnabled(v);
|
||||
|
||||
+42
-10
@@ -18,6 +18,9 @@ export type Sfx =
|
||||
|
||||
const LS_SFX = "hokm.sfx";
|
||||
const LS_MUSIC = "hokm.music";
|
||||
const LS_TRACK = "hokm.musicTrack";
|
||||
|
||||
export type MusicTrack = "santoor" | "playful";
|
||||
|
||||
function loadBool(key: string, def = true): boolean {
|
||||
if (typeof window === "undefined") return def;
|
||||
@@ -34,6 +37,8 @@ class SoundManager {
|
||||
|
||||
sfxEnabled = loadBool(LS_SFX);
|
||||
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. */
|
||||
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(
|
||||
freq: number,
|
||||
start: number,
|
||||
@@ -147,29 +164,44 @@ class SoundManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Gentle ambient loop on a Persian-flavored scale (Dastgah-ish).
|
||||
private MUSIC = [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66];
|
||||
// Two selectable loops:
|
||||
// • 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() {
|
||||
if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return;
|
||||
const playNote = () => {
|
||||
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++;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const g = this.ctx.createGain();
|
||||
const t = this.ctx.currentTime;
|
||||
osc.type = "sine";
|
||||
osc.type = cfg.type;
|
||||
osc.frequency.value = freq;
|
||||
g.gain.setValueAtTime(0.0001, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.5, t + 0.3);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6);
|
||||
g.gain.exponentialRampToValueAtTime(cfg.peak, t + cfg.attack);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + cfg.dur);
|
||||
osc.connect(g);
|
||||
g.connect(this.musicGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 1.7);
|
||||
// soft fifth harmony every other note
|
||||
if (this.step % 2 === 0) {
|
||||
osc.stop(t + cfg.dur + 0.1);
|
||||
// soft fifth harmony (santoor) every other note
|
||||
if (cfg.fifth && this.step % 2 === 0) {
|
||||
const o2 = this.ctx.createOscillator();
|
||||
const g2 = this.ctx.createGain();
|
||||
o2.type = "sine";
|
||||
@@ -184,7 +216,7 @@ class SoundManager {
|
||||
}
|
||||
};
|
||||
playNote();
|
||||
this.musicTimer = setInterval(playNote, 900);
|
||||
this.musicTimer = setInterval(playNote, this.TRACKS[this.musicTrack].gap);
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
|
||||
Reference in New Issue
Block a user