Files
MineGeo/components/StartGame.tsx
pavel444-byte da1a50a6ad
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m31s
Add map-specific tournament creation link
2026-10-18 13:32:56 +05:00

376 lines
12 KiB
TypeScript

"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn, Users, Trophy } from "lucide-react";
import {
difficulties,
customDifficultySchema,
type DifficultyPreset,
type ScoringMode,
difficultyRuleDefaults,
} from "@/shared/protocol";
import { api } from "@/lib/client-api";
import { mergeDifficultyPresets } from "@/shared/difficulty-presets";
export { api } from "@/lib/client-api";
export function StartGame({
datasetId,
hostName,
}: {
datasetId: string;
hostName: string | null;
}) {
const { t } = useI18n();
const [difficulty, setDifficulty] = useState("normal"),
[customPresets, setCustomPresets] = useState<DifficultyPreset[]>([]),
[showDifficultyForm, setShowDifficultyForm] = useState(false),
[difficultyName, setDifficultyName] = useState(""),
[difficultySeconds, setDifficultySeconds] = useState(90),
[difficultyZoom, setDifficultyZoom] = useState(true),
[difficultyRules, setDifficultyRules] = useState<{
pan: boolean;
compass: boolean;
coordinates: boolean;
mapZoom: boolean;
scoring: ScoringMode;
}>(difficultyRuleDefaults),
[savingDifficulty, setSavingDifficulty] = useState(false),
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
[playerLimit, setPlayerLimit] = useState(4),
[loading, setLoading] = useState(false),
[error, setError] = useState("");
const router = useRouter();
const tournamentPath = `/tournaments?map=${encodeURIComponent(datasetId)}`;
const starting = useRef(false);
const savingPreset = useRef(false);
useEffect(() => {
let disposed = false;
api<{ presets: DifficultyPreset[] }>("difficulty-presets")
.then(({ presets }) => {
if (!disposed)
setCustomPresets((current) =>
mergeDifficultyPresets(current, presets),
);
})
.catch((failure) => {
if (!disposed) setError(failure.message);
});
return () => {
disposed = true;
};
}, []);
const options: DifficultyPreset[] = [
...Object.entries(difficulties).map(([id, preset]) => ({
id,
name: preset.label,
seconds: preset.seconds,
zoom: preset.zoom,
})),
...customPresets,
];
async function saveDifficulty(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (savingPreset.current || starting.current) return;
const parsed = customDifficultySchema.safeParse({
name: difficultyName,
seconds: difficultySeconds,
zoom: difficultyZoom,
...difficultyRules,
});
if (!parsed.success) {
setError(parsed.error.issues[0].message);
return;
}
savingPreset.current = true;
setSavingDifficulty(true);
setError("");
try {
const { preset } = await api<{ preset: DifficultyPreset }>(
"difficulty-presets",
parsed.data,
);
setCustomPresets((current) => mergeDifficultyPresets(current, [preset]));
setDifficulty(preset.id);
setDifficultyName("");
setDifficultySeconds(90);
setDifficultyZoom(true);
setDifficultyRules(difficultyRuleDefaults);
setShowDifficultyForm(false);
} catch (failure) {
setError((failure as Error).message);
} finally {
savingPreset.current = false;
setSavingDifficulty(false);
}
}
async function start() {
if (starting.current || savingPreset.current) return;
starting.current = true;
setLoading(true);
setError("");
try {
const session = await api<{ id: string }>(
mode === "solo" ? "sessions" : "rooms",
{
datasetId,
difficulty,
...(mode === "multiplayer" ? { playerLimit } : {}),
},
);
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
} catch (e) {
starting.current = false;
setError((e as Error).message);
setLoading(false);
}
}
return (
<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>
<Link
className="button tournament-map-action full"
href={
hostName
? tournamentPath
: `/log-in?next=${encodeURIComponent(tournamentPath)}`
}
>
<Trophy size={17} />
{t(
hostName
? "Create tournament with this map"
: "Log in to create a tournament",
)}
<ArrowRight size={17} />
</Link>
<div className="difficulty-options">
{options.map((option) => (
<button
key={option.id}
aria-pressed={difficulty === option.id}
className={difficulty === option.id ? "selected" : ""}
disabled={loading}
onClick={() => setDifficulty(option.id)}
>
<strong>
{option.id in difficulties ? t(option.name) : option.name}
</strong>
<span>
<Timer size={13} />
{option.seconds}
{t("s per round")}
</span>
<span>
<ZoomIn size={13} />
{option.zoom ? t("Zoom allowed") : t("No zoom")}
</span>
{!(option.id in difficulties) && (
<span>
{t(
option.scoring === "strict"
? "Strict scoring"
: option.scoring === "gentle"
? "Gentle scoring"
: "Normal scoring",
)}{" "}
· {t(option.pan === false ? "Fixed view" : "Panning allowed")}
</span>
)}
</button>
))}
</div>
<div className="difficulty-library">
<button
type="button"
className="text-link"
aria-expanded={showDifficultyForm}
onClick={() => {
setShowDifficultyForm((visible) => !visible);
setError("");
}}
disabled={loading || savingDifficulty}
>
{showDifficultyForm
? t("Cancel")
: "+ " + t("Create custom difficulty")}
</button>
{showDifficultyForm && (
<form className="difficulty-builder" onSubmit={saveDifficulty}>
<label htmlFor="custom-difficulty-name">
{t("Difficulty name")}
</label>
<input
id="custom-difficulty-name"
value={difficultyName}
onChange={(event) =>
setDifficultyName([...event.target.value].slice(0, 40).join(""))
}
minLength={2}
maxLength={80}
required
autoComplete="off"
placeholder={t("Name this difficulty")}
disabled={savingDifficulty || loading}
/>
<label htmlFor="custom-difficulty-seconds">{t("Round time")}</label>
<div className="difficulty-builder-time">
<input
id="custom-difficulty-seconds"
type="number"
min={15}
max={600}
step={15}
value={difficultySeconds}
onChange={(event) =>
setDifficultySeconds(Number(event.target.value))
}
required
disabled={savingDifficulty || loading}
/>
<span>{t("s per round")}</span>
</div>
<label className="difficulty-builder-zoom">
<input
type="checkbox"
checked={difficultyZoom}
onChange={(event) => setDifficultyZoom(event.target.checked)}
disabled={savingDifficulty || loading}
/>
{t("Allow zoom")}
</label>
{(
[
["pan", "Allow panorama panning"],
["compass", "Show north indicator"],
["coordinates", "Show guess coordinates"],
["mapZoom", "Allow guess-map zoom"],
] as const
).map(([key, label]) => (
<label className="difficulty-builder-zoom" key={key}>
<input
type="checkbox"
checked={difficultyRules[key]}
disabled={savingDifficulty || loading}
onChange={(event) =>
setDifficultyRules((previous) => ({
...previous,
[key]: event.target.checked,
}))
}
/>
{t(label)}
</label>
))}
<label htmlFor="custom-difficulty-scoring">
{t("Scoring strictness")}
</label>
<select
id="custom-difficulty-scoring"
value={difficultyRules.scoring}
disabled={savingDifficulty || loading}
onChange={(event) =>
setDifficultyRules((previous) => ({
...previous,
scoring: event.target.value as ScoringMode,
}))
}
>
<option value="normal">{t("Normal scoring")}</option>
<option value="strict">{t("Strict scoring")}</option>
<option value="gentle">{t("Gentle scoring")}</option>
</select>
<p className="muted">
{t(
"Strict scoring penalizes distance more; gentle scoring gives more credit for nearby guesses. Every game still has five rounds.",
)}
</p>
<button
className="button primary"
disabled={savingDifficulty || loading}
>
{savingDifficulty ? t("Saving…") : t("Save difficulty")}
</button>
</form>
)}
</div>
<p className="muted">
{t("5 rounds · 25,000 possible points · No movement")}
</p>
{mode === "multiplayer" && (
<div className="multiplayer-settings">
{hostName ? (
<p>
{t("Host display name")}: <strong>{hostName}</strong>
</p>
) : (
<p className="muted">
{t("Log in to create a multiplayer room")}{" "}
<Link href="/log-in?next=/servers">{t("Log in")}</Link>
</p>
)}
<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 || savingDifficulty || (mode === "multiplayer" && !hostName)
}
>
{loading
? t("Preparing your world…")
: mode === "multiplayer"
? t("Create multiplayer room")
: t("Start exploring")}
<ArrowRight size={17} />
</button>
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
</div>
);
}