Add multiplayer rooms and Vercel analytics

This commit is contained in:
2026-10-07 12:28:45 +05:00
parent ce26e6fdbc
commit 48b34db6f1
20 changed files with 2345 additions and 9 deletions

View File

@@ -0,0 +1,367 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Copy, Users, Crown, Trophy } from "lucide-react";
import { Brand } from "./Brand";
import { MultiplayerGame } from "./MultiplayerGame";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { roundClock } from "@/shared/round-clock";
import type { RoomView } from "@/shared/multiplayer";
export function MultiplayerRoom({ id }: { id: string }) {
const { t, formatNumber } = useI18n();
const router = useRouter();
const [room, setRoom] = useState<RoomView | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [name, setName] = useState("");
const [invite, setInvite] = useState("");
const [copied, setCopied] = useState(false);
const clock = useRef<ReturnType<typeof roundClock> | null>(null);
const version = useRef(0);
const acting = useRef(false);
const latest = useRef<RoomView | null>(null);
const accept = useCallback((next: RoomView, sentAt: number) => {
clock.current = roundClock(next.serverNow, sentAt, performance.now());
latest.current = next;
setRoom(next);
setError("");
}, []);
const load = useCallback(async () => {
if (acting.current) return;
const current = ++version.current;
const sentAt = performance.now();
try {
const next = await api<RoomView>(`rooms/${id}`);
if (current === version.current) accept(next, sentAt);
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
}
}, [id, accept]);
useEffect(() => {
setInvite(`${window.location.origin}/multiplayer/${id}`);
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
await load();
if (
!stopped &&
!["finished", "closed"].includes(latest.current?.status ?? "")
)
timer = setTimeout(poll, document.hidden ? 5000 : 2000);
};
void poll();
const resume = () => {
if (!document.hidden) void load();
};
document.addEventListener("visibilitychange", resume);
return () => {
stopped = true;
version.current++;
clearTimeout(timer);
document.removeEventListener("visibilitychange", resume);
};
}, [id, load]);
const action = useCallback(
async (type: string, payload: object = {}) => {
if (acting.current) return false;
acting.current = true;
setBusy(true);
setError("");
const current = ++version.current;
const sentAt = performance.now();
try {
const next = await api<RoomView>(`rooms/${id}/${type}`, payload);
if (current === version.current) accept(next, sentAt);
return true;
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
return false;
} finally {
acting.current = false;
setBusy(false);
}
},
[id, accept],
);
const remaining = useCallback(
(deadline: string) =>
clock.current?.remaining(deadline, performance.now()) ?? 0,
[],
);
async function leave() {
if (await action("leave")) router.push("/servers");
}
async function copyInvite() {
try {
await navigator.clipboard.writeText(invite);
setCopied(true);
} catch {
setError("Copy the invite link from the field below");
}
}
const activeGame =
!!room?.you && ["loading", "playing", "results"].includes(room.status);
if (activeGame && room)
return (
<>
<MultiplayerGame
key={room.round}
id={id}
room={room}
busy={busy}
action={action}
remaining={remaining}
onLeave={leave}
/>
{error && (
<div className="multiplayer-network" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Retry")}
</button>
</div>
)}
</>
);
const owner = !!room?.you && room.you === room.hostId;
const finished = room?.status === "finished";
const winners = room?.players.filter((player) => player.rank === 1) ?? [];
return (
<>
<header className="multiplayer-header">
<Brand />
<Link href="/servers" className="text-link">
{t("Play & maps")}
</Link>
</header>
<main className="section page-content multiplayer-lobby">
{error && (
<p className="error-message" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Retry")}
</button>
</p>
)}
{!room ? (
<p className="muted">{t("Loading multiplayer room…")}</p>
) : (
<>
<span className="eyebrow">
<Users size={16} /> {t("Multiplayer")} · {room.name}
</span>
<h1>
{finished
? t("Final standings")
: room.status === "closed"
? t("This room is closed")
: t("An expedition with friends.")}
</h1>
{!room.you && room.status === "lobby" && (
<form
className="multiplayer-join"
onSubmit={(event) => {
event.preventDefault();
void action("join", { displayName: name });
}}
>
<p className="lead">
{t("Join your friends for five rounds on the same map.")}
</p>
<p>
{t("{count} / {limit} players", {
count: room.playerCount,
limit: room.playerLimit,
})}
</p>
<label htmlFor="join-player-name">
{t("Your player name")}
</label>
<input
id="join-player-name"
value={name}
onChange={(event) => setName(event.target.value)}
minLength={2}
maxLength={32}
required
autoComplete="nickname"
placeholder={t("Enter a player name")}
disabled={busy}
/>
<button
className="button primary"
disabled={busy || room.playerCount >= room.playerLimit}
>
{room.playerCount >= room.playerLimit
? t("This room is full")
: t("Join room")}
</button>
</form>
)}
{!room.you && !["lobby", "closed"].includes(room.status) && (
<p className="lead">{t("This match has already started")}</p>
)}
{room.you && room.status === "lobby" && (
<>
<p className="lead">
{owner
? t(
"Share the invite link. Start when your friends arrive.",
)
: t("You’re in! Waiting for the room owner to start.")}
</p>
<label htmlFor="room-invite">{t("Invite link")}</label>
<div className="multiplayer-invite">
<input
id="room-invite"
value={invite}
readOnly
onFocus={(event) => event.target.select()}
/>
<button className="button" onClick={copyInvite}>
<Copy size={16} /> {copied ? t("Copied!") : t("Copy link")}
</button>
</div>
<h2>
{t("{count} / {limit} players", {
count: room.playerCount,
limit: room.playerLimit,
})}
</h2>
<ul className="multiplayer-roster">
{room.players.map((player) => (
<li key={player.id}>
<span>
{player.id === room.hostId && (
<Crown size={17} aria-label={t("Room owner")} />
)}{" "}
{player.name}{" "}
{player.id === room.you && <small>{t("(you)")}</small>}
</span>
{owner && player.id !== room.you && (
<button
className="text-link"
disabled={busy}
onClick={() =>
void action("kick", { playerId: player.id })
}
aria-label={t("Remove {name}", { name: player.name })}
>
{t("Remove")}
</button>
)}
</li>
))}
</ul>
<p className="muted">
{t(
"Everyone plays the same five locations. The most points wins; equal totals share a place.",
)}
</p>
<div className="inline-actions">
{owner && (
<button
className="button primary"
disabled={busy || room.playerCount < 2}
onClick={() => void action("start")}
>
{t("Start multiplayer match")}
</button>
)}
<button className="button" disabled={busy} onClick={leave}>
{t("Leave room")}
</button>
{owner && (
<button
className="text-link"
disabled={busy}
onClick={() => void action("close")}
>
{t("Close room")}
</button>
)}
</div>
{owner && room.playerCount < 2 && (
<p className="muted">
{t("Invite at least one friend before starting")}
</p>
)}
</>
)}
{finished && room.you && (
<>
<p className="lead">
<Trophy size={24} aria-hidden="true" />{" "}
{winners.map((player) => player.name).join(" & ")} ·{" "}
{t("{count} points", {
count: formatNumber(winners[0]?.total ?? 0),
})}
</p>
<div className="multiplayer-table-wrap">
<table className="history-table multiplayer-final">
<caption>{t("Five-round totals")}</caption>
<thead>
<tr>
<th>{t("Place")}</th>
<th>{t("Player")}</th>
{room.rounds.map((turn) => (
<th key={turn.number}>
{t("Round")} {turn.number + 1}
</th>
))}
<th>{t("Total points")}</th>
</tr>
</thead>
<tbody>
{room.players.map((player) => (
<tr
key={player.id}
className={
player.id === room.you ? "multiplayer-you" : ""
}
>
<td>{player.rank}</td>
<td>
{player.name}
{player.left && <small> · {t("Left room")}</small>}
</td>
{room.rounds.map((turn) => (
<td key={turn.number}>
{formatNumber(
turn.results.find(
(result) => result.playerId === player.id,
)?.score ?? 0,
)}
</td>
))}
<td>
<strong>{formatNumber(player.total)}</strong>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Link
className="button primary"
href={`/servers/${room.datasetId}`}
>
{t("Play this map again")}
</Link>
</>
)}
{room.status === "closed" && (
<Link className="button primary" href="/servers">
{t("Another adventure →")}
</Link>
)}
</>
)}
</main>
</>
);
}