Files
MineGeo/components/MultiplayerRoom.tsx
pavel444-byte 9c314ecb4e
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
Add tournament and multiplayer community chat moderation
2026-10-27 12:54:15 +05:00

518 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { TournamentModeration } from "./TournamentModeration";
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
/>
<TournamentModeration
party
playing
id={id}
players={room.players
.filter((p) => !p.left)
.map((p) => ({ accountId: p.id, name: p.name }))}
ownerId={room.hostId}
you={room.you}
/>
</>
);
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"
id="main-content"
tabIndex={-1}
>
{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" && (
<>
<TournamentModeration
party
id={id}
players={room.players
.filter((p) => !p.left)
.map((p) => ({ accountId: p.id, name: p.name }))}
ownerId={room.hostId}
you={room.you}
/>
<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}
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}
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}
/>
</>
);
}