Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m31s
376 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|