Add Void difficulty and publish changelogs
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s
This commit is contained in:
19
components/ChangelogRefresh.tsx
Normal file
19
components/ChangelogRefresh.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function ChangelogRefresh() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
if (document.visibilityState === "visible") router.refresh();
|
||||
};
|
||||
const timer = window.setInterval(refresh, 65_000);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
};
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
@@ -217,13 +217,15 @@ export function Game({ id }: { id: string }) {
|
||||
<small>{t("ROUND")}</small>
|
||||
<strong>{session ? session.round + 1 : "—"} / 5</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>{t("TIME LEFT")}</small>
|
||||
<strong className={remaining <= 10 ? "urgent" : ""}>
|
||||
{Math.floor(remaining / 60)}:
|
||||
{String(remaining % 60).padStart(2, "0")}
|
||||
</strong>
|
||||
</div>
|
||||
{session?.difficultySettings.timerVisible !== false && (
|
||||
<div>
|
||||
<small>{t("TIME LEFT")}</small>
|
||||
<strong className={remaining <= 10 ? "urgent" : ""}>
|
||||
{Math.floor(remaining / 60)}:
|
||||
{String(remaining % 60).padStart(2, "0")}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<small>{t("POINTS")}</small>
|
||||
<strong>{formatNumber(total)}</strong>
|
||||
@@ -273,6 +275,7 @@ export function Game({ id }: { id: string }) {
|
||||
onError={onError}
|
||||
disabled={!!result || busy}
|
||||
zoom={session.difficultySettings.mapZoom}
|
||||
pan={session.difficultySettings.mapPan}
|
||||
compass={session.difficultySettings.compass}
|
||||
/>
|
||||
<div className="map-actions">
|
||||
|
||||
@@ -16,6 +16,7 @@ export function GuessMap({
|
||||
onError,
|
||||
disabled = false,
|
||||
zoom = true,
|
||||
pan = true,
|
||||
compass = true,
|
||||
}: {
|
||||
bounds: Bounds;
|
||||
@@ -27,6 +28,7 @@ export function GuessMap({
|
||||
onError?: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
zoom?: boolean;
|
||||
pan?: boolean;
|
||||
compass?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -68,11 +70,16 @@ export function GuessMap({
|
||||
touchZoom: zoom,
|
||||
doubleClickZoom: zoom,
|
||||
boxZoom: zoom,
|
||||
dragging: pan,
|
||||
keyboard: false,
|
||||
});
|
||||
if (zoom)
|
||||
L.control
|
||||
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
|
||||
.addTo(m);
|
||||
// Leaflet's keyboard handler couples panning and +/- zoom. Gate them
|
||||
// separately so a fixed map cannot be moved or zoomed by keyboard.
|
||||
if (pan && zoom) m.keyboard.enable();
|
||||
// Install cleanup immediately, including when later initialization fails.
|
||||
let observer: ResizeObserver | undefined;
|
||||
cleanup = () => {
|
||||
@@ -119,6 +126,29 @@ export function GuessMap({
|
||||
callbacks.current.onPin?.({ x, z });
|
||||
});
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.target === m.getContainer() &&
|
||||
pan &&
|
||||
!zoom &&
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(
|
||||
event.key,
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
m.panBy([
|
||||
event.key === "ArrowLeft"
|
||||
? -80
|
||||
: event.key === "ArrowRight"
|
||||
? 80
|
||||
: 0,
|
||||
event.key === "ArrowUp"
|
||||
? -80
|
||||
: event.key === "ArrowDown"
|
||||
? 80
|
||||
: 0,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key !== "Enter" ||
|
||||
event.target !== m.getContainer() ||
|
||||
@@ -171,6 +201,7 @@ export function GuessMap({
|
||||
image,
|
||||
attempt,
|
||||
zoom,
|
||||
pan,
|
||||
]);
|
||||
useEffect(() => {
|
||||
const L = lib.current,
|
||||
@@ -204,15 +235,19 @@ export function GuessMap({
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
role="application"
|
||||
aria-label={
|
||||
!compass
|
||||
!pan
|
||||
? t(
|
||||
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
|
||||
"Minecraft map. Click to place a guess, or press Enter to pin the center.",
|
||||
)
|
||||
: disabled
|
||||
? t("Minecraft map. North is up.")
|
||||
: t(
|
||||
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
|
||||
: !compass
|
||||
? t(
|
||||
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
|
||||
)
|
||||
: disabled
|
||||
? t("Minecraft map. North is up.")
|
||||
: t(
|
||||
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
|
||||
@@ -125,18 +125,20 @@ export function MultiplayerGame({
|
||||
<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>
|
||||
{room.difficultySettings.timerVisible !== false && (
|
||||
<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>
|
||||
@@ -214,6 +216,7 @@ export function MultiplayerGame({
|
||||
onReady={mapReady}
|
||||
onError={mediaError}
|
||||
zoom={room.difficultySettings.mapZoom}
|
||||
pan={room.difficultySettings.mapPan}
|
||||
compass={room.difficultySettings.compass}
|
||||
disabled={
|
||||
room.status !== "playing" || submitted || busy || seconds === 0
|
||||
|
||||
45
components/ReleaseNotes.tsx
Normal file
45
components/ReleaseNotes.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
// Render a small, safe subset of Discord Markdown; React escapes raw HTML.
|
||||
function inline(text: string) {
|
||||
return text
|
||||
.split(/(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\(https?:\/\/[^\s)]+\))/g)
|
||||
.map((part, i) => {
|
||||
if (part.startsWith("**") && part.endsWith("**"))
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith("`") && part.endsWith("`"))
|
||||
return <code key={i}>{part.slice(1, -1)}</code>;
|
||||
const link = /^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/.exec(part);
|
||||
if (link)
|
||||
return (
|
||||
<a key={i} href={link[2]} target="_blank" rel="noopener noreferrer">
|
||||
{link[1]}
|
||||
</a>
|
||||
);
|
||||
return <Fragment key={i}>{part}</Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
export function ReleaseNotes({ body }: { body: string }) {
|
||||
return body.split(/\r?\n\s*\r?\n/).map((block, i) => {
|
||||
const lines = block.split(/\r?\n/);
|
||||
if (lines.every((line) => /^\s*[-*]\s+/.test(line)))
|
||||
return (
|
||||
<ul key={i}>
|
||||
{lines.map((line, j) => (
|
||||
<li key={j}>{inline(line.replace(/^\s*[-*]\s+/, ""))}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
return (
|
||||
<p key={i}>
|
||||
{lines.map((line, j) => (
|
||||
<Fragment key={j}>
|
||||
{j > 0 && <br />}
|
||||
{inline(line)}
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export async function Footer() {
|
||||
<LanguageSwitcher />
|
||||
<Link href="/downloads">{t("Tools & downloads")}</Link>
|
||||
<Link href="/docs">{t("Docs")}</Link>
|
||||
<Link href="/changelogs">{t("Changelogs")}</Link>
|
||||
{forum && (
|
||||
<a href={forum} target="_blank" rel="noopener noreferrer">
|
||||
{t("Forum")}
|
||||
|
||||
@@ -8,6 +8,7 @@ const links = [
|
||||
["/servers", "Play & maps"],
|
||||
["/leaderboard", "Leaderboard"],
|
||||
["/tournaments", "Tournaments"],
|
||||
["/changelogs", "Changelogs"],
|
||||
["/downloads", "Downloads"],
|
||||
["/about", "Guide"],
|
||||
["/docs", "Docs"],
|
||||
|
||||
@@ -34,6 +34,8 @@ export function StartGame({
|
||||
compass: boolean;
|
||||
coordinates: boolean;
|
||||
mapZoom: boolean;
|
||||
mapPan: boolean;
|
||||
timerVisible: boolean;
|
||||
scoring: ScoringMode;
|
||||
}>(difficultyRuleDefaults),
|
||||
[savingDifficulty, setSavingDifficulty] = useState(false),
|
||||
@@ -63,6 +65,8 @@ export function StartGame({
|
||||
}, []);
|
||||
const options: DifficultyPreset[] = [
|
||||
...Object.entries(difficulties).map(([id, preset]) => ({
|
||||
...difficultyRuleDefaults,
|
||||
...preset,
|
||||
id,
|
||||
name: preset.label,
|
||||
seconds: preset.seconds,
|
||||
@@ -169,7 +173,10 @@ export function StartGame({
|
||||
<button
|
||||
key={option.id}
|
||||
aria-pressed={difficulty === option.id}
|
||||
className={difficulty === option.id ? "selected" : ""}
|
||||
className={[
|
||||
difficulty === option.id ? "selected" : "",
|
||||
option.id === "void" ? "difficulty-void" : "",
|
||||
].join(" ")}
|
||||
disabled={loading}
|
||||
onClick={() => setDifficulty(option.id)}
|
||||
>
|
||||
@@ -185,7 +192,7 @@ export function StartGame({
|
||||
<ZoomIn size={13} />
|
||||
{option.zoom ? t("Zoom allowed") : t("No zoom")}
|
||||
</span>
|
||||
{!(option.id in difficulties) && (
|
||||
{(!(option.id in difficulties) || option.id === "void") && (
|
||||
<span>
|
||||
{t(
|
||||
option.scoring === "strict"
|
||||
@@ -197,6 +204,9 @@ export function StartGame({
|
||||
· {t(option.pan === false ? "Fixed view" : "Panning allowed")}
|
||||
</span>
|
||||
)}
|
||||
{option.id === "void" && (
|
||||
<span>{t("Hardest · No compass · Fixed map")}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -238,9 +248,9 @@ export function StartGame({
|
||||
<input
|
||||
id="custom-difficulty-seconds"
|
||||
type="number"
|
||||
min={15}
|
||||
min={5}
|
||||
max={600}
|
||||
step={15}
|
||||
step={5}
|
||||
value={difficultySeconds}
|
||||
onChange={(event) =>
|
||||
setDifficultySeconds(Number(event.target.value))
|
||||
@@ -265,6 +275,8 @@ export function StartGame({
|
||||
["compass", "Show north indicator"],
|
||||
["coordinates", "Show guess coordinates"],
|
||||
["mapZoom", "Allow guess-map zoom"],
|
||||
["mapPan", "Allow guess-map panning"],
|
||||
["timerVisible", "Show round countdown"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<label className="difficulty-builder-zoom" key={key}>
|
||||
|
||||
@@ -56,6 +56,7 @@ export function TournamentIndex({
|
||||
const [clock, setClock] = useState(0);
|
||||
const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared");
|
||||
const [roundSeconds, setRoundSeconds] = useState(450);
|
||||
const [difficulty, setDifficulty] = useState("normal");
|
||||
const offset = useRef(0);
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -115,8 +116,8 @@ export function TournamentIndex({
|
||||
difficulty: form.get("difficulty"),
|
||||
playerLimit: Number(form.get("limit")),
|
||||
roundCount: Number(form.get("rounds")),
|
||||
roundTimeSeconds: Number(form.get("seconds")),
|
||||
timerMode: form.get("timerMode"),
|
||||
roundTimeSeconds: difficulty === "void" ? 10 : roundSeconds,
|
||||
timerMode: difficulty === "void" ? "perMini" : timerMode,
|
||||
startsAt: serverLocalToIso(
|
||||
String(form.get("start")),
|
||||
events.serverTimezone,
|
||||
@@ -211,10 +212,15 @@ export function TournamentIndex({
|
||||
</label>
|
||||
<label>
|
||||
{t("Difficulty")}
|
||||
<select name="difficulty" defaultValue="normal">
|
||||
<select
|
||||
name="difficulty"
|
||||
value={difficulty}
|
||||
onChange={(event) => setDifficulty(event.target.value)}
|
||||
>
|
||||
<option value="easy">{t("Easy")}</option>
|
||||
<option value="normal">{t("Normal")}</option>
|
||||
<option value="hard">{t("Hard")}</option>
|
||||
<option value="void">{t("Void")}</option>
|
||||
{presets.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
@@ -248,7 +254,8 @@ export function TournamentIndex({
|
||||
{t("Timer mode")}
|
||||
<select
|
||||
name="timerMode"
|
||||
value={timerMode}
|
||||
value={difficulty === "void" ? "perMini" : timerMode}
|
||||
disabled={difficulty === "void"}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as "shared" | "perMini";
|
||||
setTimerMode(mode);
|
||||
@@ -265,17 +272,18 @@ export function TournamentIndex({
|
||||
</label>
|
||||
<label>
|
||||
{t(
|
||||
timerMode === "shared"
|
||||
difficulty !== "void" && timerMode === "shared"
|
||||
? "Seconds per tournament round"
|
||||
: "Seconds per mini-round",
|
||||
)}
|
||||
<input
|
||||
name="seconds"
|
||||
type="number"
|
||||
min={15}
|
||||
min={difficulty === "void" ? 10 : 15}
|
||||
max={timerMode === "shared" ? 3000 : 600}
|
||||
step={15}
|
||||
value={roundSeconds}
|
||||
step={difficulty === "void" ? 5 : 15}
|
||||
value={difficulty === "void" ? 10 : roundSeconds}
|
||||
disabled={difficulty === "void"}
|
||||
onChange={(e) => setRoundSeconds(Number(e.target.value))}
|
||||
required
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user