Show live online-players count on the home screen
- OnlineService.getOnlineCount(); mock random-walks a believable number, SignalrService reads GET /api/stats/online (server tracks hub connections) - Home screen badge with pulsing dot, polls every 8s, localized digits Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -135,9 +135,18 @@ public sealed class GameManager
|
|||||||
public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit);
|
public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit);
|
||||||
public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction);
|
public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction);
|
||||||
|
|
||||||
public void OnConnected(string userId) => RoomOf(userId)?.SetConnected(userId, true);
|
private int _online;
|
||||||
|
public int OnlineCount => Volatile.Read(ref _online);
|
||||||
|
|
||||||
|
public void OnConnected(string userId)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _online);
|
||||||
|
RoomOf(userId)?.SetConnected(userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
public void OnDisconnected(string userId)
|
public void OnDisconnected(string userId)
|
||||||
{
|
{
|
||||||
|
if (Interlocked.Decrement(ref _online) < 0) Interlocked.Exchange(ref _online, 0);
|
||||||
CancelMatchmaking(userId);
|
CancelMatchmaking(userId);
|
||||||
RoomOf(userId)?.SetConnected(userId, false);
|
RoomOf(userId)?.SetConnected(userId, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ app.UseAuthentication();
|
|||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGet("/", () => Results.Json(new { service = "Hokm SignalR server", status = "ok" }));
|
app.MapGet("/", () => Results.Json(new { service = "Hokm SignalR server", status = "ok" }));
|
||||||
|
app.MapGet("/api/stats/online", (GameManager m) => Results.Json(new { online = m.OnlineCount }));
|
||||||
|
|
||||||
// --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. ---
|
// --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. ---
|
||||||
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
|
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Wifi,
|
Wifi,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore } from "@/lib/game-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";
|
||||||
|
import { getService } from "@/lib/online/service";
|
||||||
import { sound } from "@/lib/sound";
|
import { sound } from "@/lib/sound";
|
||||||
import { SUIT_SYMBOL } from "@/lib/hokm/types";
|
import { SUIT_SYMBOL } from "@/lib/hokm/types";
|
||||||
import { TopBar } from "./online/TopBar";
|
import { TopBar } from "./online/TopBar";
|
||||||
@@ -64,6 +66,7 @@ export function HomeScreen() {
|
|||||||
{t("app.title")}
|
{t("app.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-cream/60 mt-1 text-sm">{t("app.subtitle")}</p>
|
<p className="text-cream/60 mt-1 text-sm">{t("app.subtitle")}</p>
|
||||||
|
<OnlinePlayers />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* primary actions */}
|
{/* primary actions */}
|
||||||
@@ -191,6 +194,41 @@ function Tile({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OnlinePlayers() {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const [count, setCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const n = await getService().getOnlineCount();
|
||||||
|
if (alive) setCount(n);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 8000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (count == null) return null;
|
||||||
|
const n = new Intl.NumberFormat(locale === "fa" ? "fa-IR" : "en-US").format(count);
|
||||||
|
return (
|
||||||
|
<div className="mt-3 inline-flex items-center gap-2 glass rounded-full px-3 py-1.5">
|
||||||
|
<span className="relative flex size-2.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-70 animate-ping" />
|
||||||
|
<span className="relative inline-flex size-2.5 rounded-full bg-teal-400" />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-cream/85">{t("home.onlineCount", { n })}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FloatingSuits() {
|
function FloatingSuits() {
|
||||||
const suits = Object.values(SUIT_SYMBOL);
|
const suits = Object.values(SUIT_SYMBOL);
|
||||||
const items = Array.from({ length: 8 }, (_, i) => ({
|
const items = Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const fa: Dict = {
|
|||||||
"home.start": "بزن بریم",
|
"home.start": "بزن بریم",
|
||||||
"home.howTo": "آموزش بازی",
|
"home.howTo": "آموزش بازی",
|
||||||
"home.lang": "English",
|
"home.lang": "English",
|
||||||
|
"home.onlineCount": "{n} نفر آنلاین",
|
||||||
|
|
||||||
"seat.you": "شما",
|
"seat.you": "شما",
|
||||||
"team.us": "ما",
|
"team.us": "ما",
|
||||||
@@ -249,6 +250,7 @@ const en: Dict = {
|
|||||||
"home.start": "Let's go",
|
"home.start": "Let's go",
|
||||||
"home.howTo": "How to play",
|
"home.howTo": "How to play",
|
||||||
"home.lang": "فارسی",
|
"home.lang": "فارسی",
|
||||||
|
"home.onlineCount": "{n} players online",
|
||||||
|
|
||||||
"seat.you": "You",
|
"seat.you": "You",
|
||||||
"team.us": "Us",
|
"team.us": "Us",
|
||||||
|
|||||||
@@ -737,6 +737,14 @@ export class MockOnlineService implements OnlineService {
|
|||||||
|
|
||||||
/* --------------------- leaderboard / shop / daily ------------------ */
|
/* --------------------- leaderboard / shop / daily ------------------ */
|
||||||
|
|
||||||
|
private onlineCount = 600 + Math.floor(Math.random() * 900);
|
||||||
|
async getOnlineCount(): Promise<number> {
|
||||||
|
// gentle random walk so the badge feels alive
|
||||||
|
this.onlineCount += Math.round((Math.random() - 0.5) * 40);
|
||||||
|
this.onlineCount = Math.max(120, Math.min(6000, this.onlineCount));
|
||||||
|
return this.onlineCount;
|
||||||
|
}
|
||||||
|
|
||||||
async getLeaderboard(): Promise<LeaderboardEntry[]> {
|
async getLeaderboard(): Promise<LeaderboardEntry[]> {
|
||||||
const p = await this.getProfile();
|
const p = await this.getProfile();
|
||||||
const others = Array.from({ length: 24 }, () => ({
|
const others = Array.from({ length: 24 }, () => ({
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export interface OnlineService {
|
|||||||
getMatchPlayers(): { id: string; displayName: string; avatar: string; level: number }[] | null;
|
getMatchPlayers(): { id: string; displayName: string; avatar: string; level: number }[] | null;
|
||||||
submitMatchResult(summary: MatchSummary): Promise<RewardResult>;
|
submitMatchResult(summary: MatchSummary): Promise<RewardResult>;
|
||||||
|
|
||||||
|
/* ----- stats ----- */
|
||||||
|
getOnlineCount(): Promise<number>;
|
||||||
|
|
||||||
/* ----- leaderboard / shop / daily ----- */
|
/* ----- leaderboard / shop / daily ----- */
|
||||||
getLeaderboard(): Promise<LeaderboardEntry[]>;
|
getLeaderboard(): Promise<LeaderboardEntry[]>;
|
||||||
getShopItems(): Promise<ShopItem[]>;
|
getShopItems(): Promise<ShopItem[]>;
|
||||||
|
|||||||
@@ -262,6 +262,19 @@ export class SignalrService implements OnlineService {
|
|||||||
markRead(id: string) { return this.mock.markRead(id); }
|
markRead(id: string) { return this.mock.markRead(id); }
|
||||||
onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); }
|
onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); }
|
||||||
|
|
||||||
|
async getOnlineCount(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SERVER}/api/stats/online`);
|
||||||
|
if (res.ok) {
|
||||||
|
const j = (await res.json()) as { online: number };
|
||||||
|
return j.online ?? 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
return this.mock.getOnlineCount();
|
||||||
|
}
|
||||||
|
|
||||||
getLeaderboard(): Promise<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
|
getLeaderboard(): Promise<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
|
||||||
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }
|
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }
|
||||||
buyItem(id: string) { return this.mock.buyItem(id); }
|
buyItem(id: string) { return this.mock.buyItem(id); }
|
||||||
|
|||||||
Reference in New Issue
Block a user