All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
497 lines
18 KiB
TypeScript
497 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { Copy, Users, Crown, Trophy, MessageCircle } from "lucide-react";
|
||
import { Brand } from "./Brand";
|
||
import { MultiplayerGame } from "./MultiplayerGame";
|
||
import { RoomChat } from "./RoomChat";
|
||
import { useI18n } from "./I18nProvider";
|
||
import { api } from "@/lib/client-api";
|
||
import { roundClock } from "@/shared/round-clock";
|
||
import type { RoomView } from "@/shared/multiplayer";
|
||
import { Username } from "./Username";
|
||
import { PlayerAvatar } from "./PixelArt";
|
||
import { difficulties } from "@/shared/protocol";
|
||
|
||
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 [sharing, setSharing] = useState(false);
|
||
const [shareMessage, setShareMessage] = useState("");
|
||
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");
|
||
}
|
||
}
|
||
async function publishParty() {
|
||
if (sharing) return;
|
||
setSharing(true);
|
||
setShareMessage("");
|
||
try {
|
||
await api(`rooms/${id}/discord`, {});
|
||
setShareMessage(
|
||
"Party announcement queued. Anyone in the Discord channel can open the invite and join.",
|
||
);
|
||
await load();
|
||
} catch (failure) {
|
||
setShareMessage((failure as Error).message);
|
||
} finally {
|
||
setSharing(false);
|
||
}
|
||
}
|
||
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>
|
||
)}
|
||
<RoomChat
|
||
key={id}
|
||
id={id}
|
||
playerId={room.you}
|
||
available={room.status !== "closed"}
|
||
playing
|
||
/>
|
||
</>
|
||
);
|
||
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>
|
||
<p className="muted">
|
||
{t("Difficulty")}: <strong>
|
||
{room.difficultySettings.id in difficulties
|
||
? t(room.difficultySettings.name)
|
||
: room.difficultySettings.name}
|
||
</strong>
|
||
{" · "}
|
||
{room.difficultySettings.seconds} {t("s per round")} ·{" "}
|
||
{room.difficultySettings.zoom
|
||
? t("Zoom allowed")
|
||
: t("No zoom")}
|
||
</p>
|
||
<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>
|
||
{owner && (room.discord?.available || room.discord?.state) && (
|
||
<div className="discord-party-share">
|
||
<p className="muted">
|
||
{t(
|
||
"Publishing shares this invite with everyone in the community Discord channel.",
|
||
)}
|
||
</p>
|
||
{room.discord.state === null && (
|
||
<button
|
||
className="button"
|
||
disabled={
|
||
sharing ||
|
||
busy ||
|
||
room.playerCount >= room.playerLimit
|
||
}
|
||
onClick={() => void publishParty()}
|
||
>
|
||
<MessageCircle size={16} aria-hidden="true" />
|
||
{sharing
|
||
? t("Publishing…")
|
||
: t("Publish party to Discord")}
|
||
</button>
|
||
)}
|
||
{room.discord.state === "sent" && (
|
||
<p role="status">{t("Party posted to Discord.")}</p>
|
||
)}
|
||
{["pending", "sending"].includes(
|
||
room.discord.state ?? "",
|
||
) && (
|
||
<p role="status">
|
||
{t(
|
||
"Party announcement is waiting for Discord delivery.",
|
||
)}
|
||
</p>
|
||
)}
|
||
{room.discord.state === "failed" && (
|
||
<p role="status">
|
||
{t(
|
||
"Discord delivery needs an administrator to check the bot setup and retry.",
|
||
)}
|
||
</p>
|
||
)}
|
||
{room.discord.state === "skipped" && (
|
||
<p role="status">
|
||
{t(
|
||
"This party announcement expired or the lobby is no longer available.",
|
||
)}
|
||
</p>
|
||
)}
|
||
{shareMessage && !room.discord.state && (
|
||
<p role="status">{t(shareMessage)}</p>
|
||
)}
|
||
</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>
|
||
<PlayerAvatar
|
||
id={player.id}
|
||
avatarPath={player.appearance?.avatar_path}
|
||
effect={player.appearance?.profile_effect}
|
||
className="multiplayer-avatar"
|
||
/>
|
||
{player.id === room.hostId && (
|
||
<Crown size={17} aria-label={t("Room owner")} />
|
||
)}{" "}
|
||
<Username
|
||
name={player.name}
|
||
appearance={player.appearance}
|
||
/>{" "}
|
||
{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, index) => (
|
||
<span key={player.id}>
|
||
{index > 0 ? " & " : ""}
|
||
<Username
|
||
name={player.name}
|
||
appearance={player.appearance}
|
||
/>
|
||
</span>
|
||
))}{" "}
|
||
·{" "}
|
||
{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>
|
||
<PlayerAvatar
|
||
id={player.id}
|
||
avatarPath={player.appearance?.avatar_path}
|
||
effect={player.appearance?.profile_effect}
|
||
className="multiplayer-final-avatar"
|
||
/>
|
||
<Username
|
||
name={player.name}
|
||
appearance={player.appearance}
|
||
/>
|
||
{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>
|
||
<RoomChat
|
||
key={id}
|
||
id={id}
|
||
playerId={room?.you ?? null}
|
||
available={!!room && room.status !== "closed"}
|
||
playing={false}
|
||
/>
|
||
</>
|
||
);
|
||
}
|