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,314 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Maximize2,
Minimize2,
MapPin,
Map as MapIcon,
Users,
} from "lucide-react";
import { Brand } from "./Brand";
import { Panorama } from "./Panorama";
import { GuessMap, type Pin } from "./GuessMap";
import { ReportContent } from "./ReportContent";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { difficulties } from "@/shared/protocol";
import type { RoomView, RoomMedia } from "@/shared/multiplayer";
export function MultiplayerGame({
id,
room,
busy,
action,
remaining,
onLeave,
}: {
id: string;
room: RoomView;
busy: boolean;
action: (type: string, payload?: object) => Promise<boolean>;
remaining: (deadline: string) => number;
onLeave: () => void;
}) {
const { t, formatNumber } = useI18n();
const [media, setMedia] = useState<RoomMedia | null>(null);
const [error, setError] = useState("");
const [pin, setPin] = useState<Pin | null>(null);
const [expanded, setExpanded] = useState(false);
const [loaded, setLoaded] = useState({ panorama: false, map: false });
const [seconds, setSeconds] = useState(0);
const [nextIn, setNextIn] = useState(0);
const sendingReady = useRef(false);
const me = room.players.find((player) => player.id === room.you);
const turn = room.rounds[room.round];
const result = turn.results.find((result) => result.playerId === room.you);
const owner = room.hostId === room.you;
const submitted = me?.submitted ?? false;
const deadline = turn.deadline;
useEffect(() => {
let disposed = false;
api<RoomMedia>(`rooms/${id}/media`)
.then((next) => {
if (!disposed && next.round === room.round) setMedia(next);
})
.catch((failure) => {
if (!disposed) setError(failure.message);
});
return () => {
disposed = true;
};
}, [id, room.round]);
useEffect(() => {
if (
room.status !== "loading" ||
!loaded.panorama ||
!loaded.map ||
me?.ready ||
sendingReady.current ||
busy ||
error
)
return;
sendingReady.current = true;
void action("ready", { round: room.round }).finally(() => {
sendingReady.current = false;
});
}, [room.status, room.round, me?.ready, loaded, busy, error, action]);
useEffect(() => {
const tick = () => {
setSeconds(deadline ? remaining(deadline) : 0);
setNextIn(room.transitionAt ? remaining(room.transitionAt) : 0);
};
tick();
const timer = setInterval(tick, 250);
return () => clearInterval(timer);
}, [deadline, room.transitionAt, remaining]);
const panoramaReady = useCallback(
() => setLoaded((previous) => ({ ...previous, panorama: true })),
[],
);
const mapReady = useCallback(
() => setLoaded((previous) => ({ ...previous, map: true })),
[],
);
const mediaError = useCallback((message: string) => setError(message), []);
const roundScores = [...turn.results].sort((a, b) => b.score - a.score);
return (
<main className="game-page multiplayer-game">
{media && (
<Panorama
urls={media.faces}
zoom={difficulties[room.difficulty].zoom}
onReady={panoramaReady}
onError={mediaError}
/>
)}
<div className="game-header">
<Brand />
<ReportContent datasetId={room.datasetId} />
<div className="game-info">
<div>
<small>{t("ROUND")}</small>
<strong>{room.round + 1} / 5</strong>
</div>
<div>
<small>{t("TIME LEFT")}</small>
<strong
className={
room.status === "playing" && seconds <= 10 ? "urgent" : ""
}
>
{room.status === "loading"
? t("Loading…")
: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`}
</strong>
</div>
<div>
<small>{t("POINTS")}</small>
<strong>{formatNumber(me?.total ?? 0)}</strong>
</div>
</div>
</div>
<details className="multiplayer-scoreboard">
<summary>
<Users size={16} /> {t("Players")} · {room.playerCount}
</summary>
<ul>
{room.players.map((player) => (
<li key={player.id}>
<span>
{player.name}
{player.id === room.you && <small> {t("(you)")}</small>}
<small>
{player.left
? t("Left room")
: room.status === "loading"
? player.ready
? t("Ready")
: t("Loading…")
: player.submitted
? t("Guess submitted")
: t("Guessing")}
</small>
</span>
<strong>{formatNumber(player.total)}</strong>
</li>
))}
</ul>
<button className="text-link" onClick={onLeave} disabled={busy}>
{t("Leave room")}
</button>
</details>
{media && (
<div className={"game-map " + (expanded ? "expanded" : "")}>
<button
className="mobile-map-toggle"
aria-expanded={expanded}
aria-controls="multiplayer-guess-map"
onClick={() => setExpanded(true)}
>
<MapIcon size={20} aria-hidden="true" />
<span>{pin ? t("Edit your guess") : t("Open guess map")}</span>
</button>
<div className="game-map-content" id="multiplayer-guess-map">
<div className="map-topline">
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
<button
aria-label={expanded ? t("Collapse map") : t("Expand map")}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
</div>
<GuessMap
bounds={media.bounds}
image={media.map}
pin={result ? result.guess : (room.myGuess ?? pin)}
actual={result?.actual}
onPin={setPin}
onReady={mapReady}
onError={mediaError}
disabled={
room.status !== "playing" || submitted || busy || seconds === 0
}
/>
<div className="map-actions">
<small>
{submitted
? t("Guess locked. Waiting for the other players.")
: pin
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
: t("Click the map to drop a pin")}
</small>
<button
className="button primary full"
disabled={
!pin ||
busy ||
submitted ||
room.status !== "playing" ||
seconds === 0
}
onClick={() =>
pin && void action("guess", { round: room.round, ...pin })
}
>
<MapPin size={15} />{" "}
{submitted ? t("Guess submitted") : t("Submit guess")}
</button>
</div>
</div>
</div>
)}
{room.status === "playing" && submitted && (
<div className="multiplayer-waiting" role="status">
{t("Guess locked. Waiting for the other players.")}
</div>
)}
{room.status === "playing" && seconds === 0 && !submitted && (
<div className="multiplayer-waiting" role="status">
{t("Time is up. Calculating round scores…")}
</div>
)}
{(!media ||
!loaded.panorama ||
!loaded.map ||
room.status === "loading") &&
!error &&
room.status !== "results" && (
<div className="game-loading">
<div className="loading-cube" />
<h2>{t("Preparing your expedition.")}</h2>
<p>
{loaded.panorama && loaded.map
? t("Waiting for players to load the shared round…")
: t("Loading panorama and surface map…")}
</p>
{room.status === "loading" && (
<small>
{t("Starting within {seconds}s", { seconds: nextIn })}
</small>
)}
</div>
)}
{error && (
<div className="round-result" role="alert">
<h2>{t("Connection interrupted.")}</h2>
<p>{t(error)}</p>
<button
className="button primary"
onClick={() => window.location.reload()}
>
{t("Reconnect")}
</button>
</div>
)}
{room.status === "results" && !error && (
<div className="round-result multiplayer-round-result">
<span className="eyebrow">
{t("ROUND")} {room.round + 1} {t("COMPLETE")}
</span>
<h2>{t("Round standings")}</h2>
<div className="score">
{formatNumber(result?.score ?? 0)}
<small> / 5,000</small>
</div>
<p>
{result?.timedOut
? t("The clock ran out. This round scores zero.")
: t("{count} blocks from the actual location.", {
count: formatNumber(Math.round(result?.distance ?? 0)),
})}
</p>
<ol className="multiplayer-round-scores">
{roundScores.map((score) => (
<li key={score.playerId}>
<span>
{
room.players.find((player) => player.id === score.playerId)
?.name
}
</span>
<strong>{formatNumber(score.score)}</strong>
</li>
))}
</ol>
{owner && (
<button
className="button primary full"
disabled={busy}
onClick={() => void action("next", { round: room.round })}
>
{t("Next round")} →
</button>
)}
<p className="muted">
{t("Next round in {seconds}s", { seconds: nextIn })}
</p>
</div>
)}
</main>
);
}

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>
</>
);
}

View File

@@ -3,13 +3,16 @@ import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
import { difficulties, type Difficulty } from "@/shared/protocol";
import { api } from "@/lib/client-api";
export { api } from "@/lib/client-api";
export function StartGame({ datasetId }: { datasetId: string }) {
const { t } = useI18n();
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
[displayName, setDisplayName] = useState(""),
[playerLimit, setPlayerLimit] = useState(4),
[loading, setLoading] = useState(false),
[error, setError] = useState("");
const router = useRouter();
@@ -17,11 +20,15 @@ export function StartGame({ datasetId }: { datasetId: string }) {
setLoading(true);
setError("");
try {
const session = await api<{ id: string }>("sessions", {
datasetId,
difficulty,
});
router.push("/play/" + session.id);
const session = await api<{ id: string }>(
mode === "solo" ? "sessions" : "rooms",
{
datasetId,
difficulty,
...(mode === "multiplayer" ? { displayName, playerLimit } : {}),
},
);
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
} catch (e) {
setError((e as Error).message);
setLoading(false);
@@ -31,6 +38,24 @@ export function StartGame({ datasetId }: { datasetId: string }) {
<div className="start-panel">
<span className="eyebrow">{t("YOUR EXPEDITION")}</span>
<h2>{t("Choose your challenge.")}</h2>
<div className="tabs" aria-label={t("Game mode")}>
<button
aria-pressed={mode === "solo"}
className={mode === "solo" ? "active" : ""}
disabled={loading}
onClick={() => setMode("solo")}
>
{t("Solo")}
</button>
<button
aria-pressed={mode === "multiplayer"}
className={mode === "multiplayer" ? "active" : ""}
disabled={loading}
onClick={() => setMode("multiplayer")}
>
<Users size={15} /> {t("Multiplayer")}
</button>
</div>
<div className="difficulty-options">
{Object.entries(difficulties).map(([key, value]) => (
<button
@@ -55,12 +80,53 @@ export function StartGame({ datasetId }: { datasetId: string }) {
<p className="muted">
{t("5 rounds · 25,000 possible points · No movement")}
</p>
{mode === "multiplayer" && (
<div className="multiplayer-settings">
<label htmlFor="room-display-name">{t("Your player name")}</label>
<input
id="room-display-name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
minLength={2}
maxLength={32}
placeholder={t("Enter a player name")}
autoComplete="nickname"
disabled={loading}
/>
<label htmlFor="room-player-limit">
{t("Player limit (including you)")}
</label>
<select
id="room-player-limit"
value={playerLimit}
onChange={(event) => setPlayerLimit(Number(event.target.value))}
disabled={loading}
>
{Array.from({ length: 15 }, (_, i) => i + 2).map((count) => (
<option key={count} value={count}>
{count}
</option>
))}
</select>
<p className="muted">
{t(
"Create a room, share the invite link, and start when your friends arrive.",
)}
</p>
</div>
)}
<button
className="button primary full"
onClick={start}
disabled={loading}
disabled={
loading || (mode === "multiplayer" && displayName.trim().length < 2)
}
>
{loading ? t("Preparing your world…") : t("Start exploring")}
{loading
? t("Preparing your world…")
: mode === "multiplayer"
? t("Create multiplayer room")
: t("Start exploring")}
<ArrowRight size={17} />
</button>
{error && (