Add multiplayer rooms and Vercel analytics
This commit is contained in:
367
components/MultiplayerRoom.tsx
Normal file
367
components/MultiplayerRoom.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user