316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
"use client";
|
|
import { Username } from "./Username";
|
|
|
|
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>
|
|
<Username name={player.name} appearance={player.appearance} />
|
|
{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>
|
|
);
|
|
}
|