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