Files
MineGeo/components/MultiplayerGame.tsx

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