Improve MapPoolSelector UI

This commit is contained in:
Remmy Cat Stock
2022-10-25 22:54:58 +02:00
parent 3b18598288
commit edd3a1d91b
17 changed files with 355 additions and 156 deletions

View File

@@ -3,23 +3,73 @@ import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import { modes, stageIds } from "~/modules/in-game-lists";
import { type MapPool } from "~/modules/map-pool-serializer";
import { MapPool } from "~/modules/map-pool-serializer";
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
import { Button } from "~/components/Button";
import { CrossIcon } from "./icons/Cross";
import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft";
import * as React from "react";
export type MapPoolSelectorProps = {
mapPool: MapPool;
handleRemoval?: () => void;
handleMapPoolChange: (mapPool: MapPool) => void;
className?: string;
};
export function MapPoolSelector({
mapPool,
handleMapPoolChange,
}: {
handleRemoval,
className,
}: MapPoolSelectorProps) {
const { t } = useTranslation();
const handleStageModesChange = (newMapPool: MapPool) => {
handleMapPoolChange(newMapPool);
};
const handleClear = () => {
handleMapPoolChange(MapPool.EMPTY);
};
return (
<fieldset className={className}>
<legend>{t("maps.mapPool")}</legend>
<div className="stack md">
<div className="stack horizontal sm justify-end">
{handleRemoval && (
<Button variant="minimal" onClick={handleRemoval}>
{t("actions.remove")}
</Button>
)}
<Button
variant="minimal-destructive"
disabled={mapPool.isEmpty()}
onClick={handleClear}
>
{t("actions.clear")}
</Button>
</div>
<MapPoolStages
mapPool={mapPool}
handleMapPoolChange={handleStageModesChange}
/>
</div>
</fieldset>
);
}
export type MapPoolStagesProps = {
mapPool: MapPool;
handleMapPoolChange?: ({
mode,
stageId,
}: {
mode: ModeShort;
stageId: StageId;
}) => void;
}) {
const { t } = useTranslation(["game-misc"]);
handleMapPoolChange?: (newMapPool: MapPool) => void;
};
export function MapPoolStages({
mapPool,
handleMapPoolChange,
}: MapPoolStagesProps) {
const { t } = useTranslation(["game-misc", "common"]);
const isPresentational = !handleMapPoolChange;
@@ -29,6 +79,52 @@ export function MapPoolSelector({
return mapPool.hasStage(stageId);
};
const handleModeChange = ({
mode,
stageId,
}: {
mode: ModeShort;
stageId: StageId;
}) => {
const newMapPool = mapPool.parsed[mode].includes(stageId)
? new MapPool({
...mapPool.parsed,
[mode]: mapPool.parsed[mode].filter((id) => id !== stageId),
})
: new MapPool({
...mapPool.parsed,
[mode]: [...mapPool.parsed[mode], stageId],
});
handleMapPoolChange?.(newMapPool);
};
const handleStageClear = (stageId: StageId) => {
const newMapPool = new MapPool({
TW: mapPool.parsed.TW.filter((id) => id !== stageId),
SZ: mapPool.parsed.SZ.filter((id) => id !== stageId),
TC: mapPool.parsed.TC.filter((id) => id !== stageId),
RM: mapPool.parsed.RM.filter((id) => id !== stageId),
CB: mapPool.parsed.CB.filter((id) => id !== stageId),
});
handleMapPoolChange?.(newMapPool);
};
const handleStageAdd = (stageId: StageId) => {
const newMapPool = new MapPool({
TW: [...mapPool.parsed.TW, stageId],
SZ: [...mapPool.parsed.SZ, stageId],
TC: [...mapPool.parsed.TC, stageId],
RM: [...mapPool.parsed.RM, stageId],
CB: [...mapPool.parsed.CB, stageId],
});
handleMapPoolChange?.(newMapPool);
};
const id = React.useId();
return (
<div className="stack md">
{stageIds.filter(stageRowIsVisible).map((stageId) => (
@@ -40,8 +136,14 @@ export function MapPoolSelector({
width={80}
height={45}
/>
<div className="maps__stage-name-row">
<div>{t(`game-misc:STAGE_${stageId}`)}</div>
<div
className="maps__stage-name-row"
role="group"
aria-labelledby={`${id}-stage-name-${stageId}`}
>
<div id={`${id}-stage-name-${stageId}`}>
{t(`game-misc:STAGE_${stageId}`)}
</div>
<div className="maps__mode-buttons-container">
{modes.map((mode) => {
const selected = mapPool.parsed[mode.short].includes(stageId);
@@ -54,7 +156,8 @@ export function MapPoolSelector({
className={clsx("maps__mode", {
selected,
})}
alt={mode.long}
title={t(`game-misc:MODE_LONG_${mode.short}`)}
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
path={modeImageUrl(mode.short)}
width={33}
height={33}
@@ -69,15 +172,18 @@ export function MapPoolSelector({
selected,
})}
onClick={() =>
handleMapPoolChange?.({ mode: mode.short, stageId })
handleModeChange?.({ mode: mode.short, stageId })
}
type="button"
title={t(`game-misc:MODE_LONG_${mode.short}`)}
aria-describedby={`${id}-stage-name-${stageId}`}
aria-pressed={selected}
>
<Image
className={clsx("maps__mode", {
selected,
})}
alt={mode.long}
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
path={modeImageUrl(mode.short)}
width={20}
height={20}
@@ -85,6 +191,28 @@ export function MapPoolSelector({
</button>
);
})}
{!isPresentational &&
(mapPool.hasStage(stageId) ? (
<Button
key="clear"
onClick={() => handleStageClear(stageId)}
icon={<CrossIcon />}
variant="minimal"
aria-label={t("common:actions.remove")}
title={t("common:actions.remove")}
tiny
/>
) : (
<Button
key="select-all"
onClick={() => handleStageAdd(stageId)}
icon={<ArrowLongLeftIcon />}
variant="minimal"
aria-label={t("common:actions.selectAll")}
title={t("common:actions.selectAll")}
tiny
/>
))}
</div>
</div>
</div>

View File

@@ -0,0 +1,18 @@
export function ArrowLongLeftIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 15.75L3 12m0 0l3.75-3.75M3 12h18"
/>
</svg>
);
}

10
app/hooks/useOnce.ts Normal file
View File

@@ -0,0 +1,10 @@
import { useMemo } from "react";
/**
* Utility hook for calling `useMemo(f, [])`, when you're sure it needs no
* revalidation but feel bad for getting shamed by eslint everytime :D
*/
export function useOnce<T>(factory: () => T) {
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(factory, []);
}

View File

@@ -1,24 +1,9 @@
export const modes = [
{
short: "TW",
long: "Turf War",
},
{
short: "SZ",
long: "Splat Zones",
},
{
short: "TC",
long: "Tower Control",
},
{
short: "RM",
long: "Rainmaker",
},
{
short: "CB",
long: "Clam Blitz",
},
{ short: "TW" },
{ short: "SZ" },
{ short: "TC" },
{ short: "RM" },
{ short: "CB" },
] as const;
export const modesShort = modes.map((mode) => mode.short);

View File

@@ -6,7 +6,11 @@ import type { ReadonlyMapPoolObject, MapPoolObject } from "./types";
import clone from "just-clone";
import type { MapPoolMap } from "~/db/types";
import { mapPoolListToMapPoolObject } from "~/modules/map-list-generator";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import {
type ModeShort,
type StageId,
stageIds,
} from "~/modules/in-game-lists";
type DbMapPoolList = Array<Pick<MapPoolMap, "stageId" | "mode">>;
@@ -84,4 +88,20 @@ export class MapPool {
toJSON() {
return this.parsed;
}
static EMPTY = new MapPool({
SZ: [],
TC: [],
CB: [],
RM: [],
TW: [],
});
static ANARCHY = new MapPool({
SZ: [...stageIds],
TC: [...stageIds],
CB: [...stageIds],
RM: [...stageIds],
TW: [],
});
}

View File

@@ -33,13 +33,13 @@ import { discordFullName, makeTitle } from "~/utils/strings";
import {
calendarEditPage,
calendarReportWinnersPage,
mapsPage,
navIconUrl,
readonlyMapsPage,
resolveBaseUrl,
userPage,
} from "~/utils/urls";
import { actualNumber, id } from "~/utils/zod";
import { MapPoolSelector } from "~/components/MapPoolSelector";
import { MapPoolStages } from "~/components/MapPoolSelector";
import { Tags } from "../components/Tags";
import { MapPool } from "~/modules/map-pool-serializer";
@@ -245,10 +245,10 @@ function MapPoolInfo() {
return (
<Section title="Map pool">
<div className="event__map-pool-section">
<MapPoolSelector mapPool={new MapPool(data.mapPool)} />
<MapPoolStages mapPool={new MapPool(data.mapPool)} />
<LinkButton
className="event__create-map-list-link"
to={mapsPage(data.event.eventId)}
to={readonlyMapsPage(data.event.eventId)}
variant="outlined"
tiny
>

View File

@@ -20,14 +20,12 @@ import { TrashIcon } from "~/components/icons/Trash";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { Toggle } from "~/components/Toggle";
import { CALENDAR_EVENT } from "~/constants";
import { db } from "~/db";
import type { Badge as BadgeType, CalendarEventTag } from "~/db/types";
import { useIsMounted } from "~/hooks/useIsMounted";
import { requireUser } from "~/modules/auth";
import { i18next } from "~/modules/i18n";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import { MapPool } from "~/modules/map-pool-serializer";
import { canEditCalendarEvent } from "~/permissions";
import calendarNewStyles from "~/styles/calendar-new.css";
@@ -519,61 +517,41 @@ function BadgesAdder() {
);
}
const DEFAULT_MAP_POOL = new MapPool({
SZ: [],
TC: [],
CB: [],
RM: [],
TW: [],
});
function MapPoolSection() {
const { t } = useTranslation(["game-misc", "calendar"]);
const { t } = useTranslation(["game-misc", "common"]);
const { eventToEdit } = useLoaderData<typeof loader>();
const [mapPool, setMapPool] = React.useState<MapPool>(
eventToEdit?.mapPool ? new MapPool(eventToEdit.mapPool) : DEFAULT_MAP_POOL
eventToEdit?.mapPool ? new MapPool(eventToEdit.mapPool) : MapPool.EMPTY
);
const [includeMapPool, setIncludeMapPool] = React.useState(
Boolean(eventToEdit?.mapPool)
);
const handleMapPoolChange = ({
mode,
stageId,
}: {
mode: ModeShort;
stageId: StageId;
}) => {
const newMapPool = new MapPool(
mapPool.parsed[mode].includes(stageId)
? {
...mapPool.parsed,
[mode]: mapPool.parsed[mode].filter((id) => id !== stageId),
}
: {
...mapPool.parsed,
[mode]: [...mapPool.parsed[mode], stageId],
}
);
const id = React.useId();
setMapPool(newMapPool);
};
return includeMapPool ? (
<>
<input type="hidden" name="pool" value={mapPool.serialized} />
return (
<div className="w-full">
{includeMapPool && (
<input type="hidden" name="pool" value={mapPool.serialized} />
)}
<Label>{t("calendar:forms.mapPool")}</Label>
<div className="stack md">
<Toggle checked={includeMapPool} setChecked={setIncludeMapPool} tiny />
{includeMapPool && (
<MapPoolSelector
mapPool={mapPool}
handleMapPoolChange={handleMapPoolChange}
/>
)}
</div>
<MapPoolSelector
className="w-full"
mapPool={mapPool}
handleRemoval={() => setIncludeMapPool(false)}
handleMapPoolChange={setMapPool}
/>
</>
) : (
<div>
<label htmlFor={id}>{t("common:maps.mapPool")}</label>
<Button
id={id}
variant="outlined"
tiny
onClick={() => setIncludeMapPool(true)}
>
{t("common:actions.add")}
</Button>
</div>
);
}

View File

@@ -17,12 +17,7 @@ import { Main } from "~/components/Main";
import { Toggle } from "~/components/Toggle";
import { db } from "~/db";
import { i18next } from "~/modules/i18n";
import {
stageIds,
type ModeShort,
type ModeWithStage,
type StageId,
} from "~/modules/in-game-lists";
import { stageIds, type ModeWithStage } from "~/modules/in-game-lists";
import {
generateMapList,
mapPoolToNonEmptyModes,
@@ -33,11 +28,18 @@ import styles from "~/styles/maps.css";
import { makeTitle } from "~/utils/strings";
import { calendarEventPage, ipLabsMaps } from "~/utils/urls";
import { type SendouRouteHandle } from "~/utils/remix";
import { MapPoolSelector } from "~/components/MapPoolSelector";
import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector";
import { EditIcon } from "~/components/icons/Edit";
import { useOnce } from "~/hooks/useOnce";
const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2;
export const unstable_shouldReload: ShouldReloadFunction = () => false;
export const unstable_shouldReload: ShouldReloadFunction = ({ url }) => {
const searchParams = new URL(url).searchParams;
// Only let loader reload data if we're not currently editing the map pool
// and persisting it in the search params.
return searchParams.has("readonly");
};
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
@@ -81,34 +83,43 @@ export const loader = async ({ request }: LoaderArgs) => {
};
};
const DEFAULT_MAP_POOL = new MapPool({
SZ: [...stageIds],
TC: [...stageIds],
CB: [...stageIds],
RM: [...stageIds],
TW: [],
});
export default function MapListPage() {
const { t } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const { mapPool, handleMapPoolChange } = useSearchParamMapPool();
const { mapPool, handleMapPoolChange, readonly, switchToEditMode } =
useSearchParamPersistedMapPool();
return (
<Main className="maps__container stack lg">
{data.calendarEvent && !searchParams.has("pool") && (
<div className="maps__pool-info">
{t("common:maps.mapPool")}:{" "}
<Link to={calendarEventPage(data.calendarEvent.id)}>
{data.calendarEvent.name}
</Link>
{searchParams.has("readonly") && data.calendarEvent && (
<div className="maps__pool-meta">
<div className="maps__pool-info">
{t("common:maps.mapPool")}:{" "}
{
<Link to={calendarEventPage(data.calendarEvent.id)}>
{data.calendarEvent.name}
</Link>
}
</div>
<Button
variant="outlined"
onClick={switchToEditMode}
tiny
icon={<EditIcon />}
>
{t("common:actions.edit")}
</Button>
</div>
)}
<MapPoolSelector
mapPool={mapPool}
handleMapPoolChange={handleMapPoolChange}
/>
{readonly ? (
<MapPoolStages mapPool={mapPool} />
) : (
<MapPoolSelector
mapPool={mapPool}
handleMapPoolChange={handleMapPoolChange}
/>
)}
<a
href={ipLabsMaps(mapPool.serialized)}
target="_blank"
@@ -122,41 +133,26 @@ export default function MapListPage() {
);
}
function useSearchParamMapPool() {
function useSearchParamPersistedMapPool() {
const data = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const mapPool = (() => {
const initialMapPool = useOnce(() => {
if (searchParams.has("pool")) {
return new MapPool(searchParams.get("pool")!);
}
if (data?.mapPool) {
if (data.mapPool) {
return new MapPool(data.mapPool);
}
return DEFAULT_MAP_POOL;
})();
return MapPool.ANARCHY;
});
const handleMapPoolChange = ({
mode,
stageId,
}: {
mode: ModeShort;
stageId: StageId;
}) => {
const newMapPool = new MapPool(
mapPool.parsed[mode].includes(stageId)
? {
...mapPool.parsed,
[mode]: mapPool.parsed[mode].filter((id) => id !== stageId),
}
: {
...mapPool.parsed,
[mode]: [...mapPool.parsed[mode], stageId],
}
);
const [mapPool, setMapPool] = React.useState(initialMapPool);
const handleMapPoolChange = (newMapPool: MapPool) => {
setMapPool(newMapPool);
setSearchParams(
{
pool: newMapPool.serialized,
@@ -165,9 +161,20 @@ function useSearchParamMapPool() {
);
};
const switchToEditMode = () => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.delete("readonly");
setSearchParams(newSearchParams, {
replace: false,
state: { scroll: false },
});
};
return {
mapPool,
readonly: searchParams.has("readonly"),
handleMapPoolChange,
switchToEditMode,
};
}
@@ -192,13 +199,16 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) {
setMapList(list);
};
const disabled =
mapPool.isEmpty() || (szEveryOther && !mapPool.hasMode("SZ"));
return (
<div className="maps__map-list-creator">
<div className="maps__toggle-container">
<Label>{t("common:maps.halfSz")}</Label>
<Toggle checked={szEveryOther} setChecked={setSzEveryOther} tiny />
</div>
<Button onClick={handleCreateMaplist}>
<Button onClick={handleCreateMaplist} disabled={disabled}>
{t("common:maps.createMapList")}
</Button>
{mapList && (
@@ -206,7 +216,12 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) {
<ol className="maps__map-list">
{mapList.map(({ mode, stageId }, i) => (
<li key={i}>
{t(`game-misc:MODE_SHORT_${mode}`)}{" "}
<abbr
className="maps__mode-abbr"
title={t(`game-misc:MODE_LONG_${mode}`)}
>
{t(`game-misc:MODE_SHORT_${mode}`)}
</abbr>{" "}
{t(`game-misc:STAGE_${stageId}`)}
</li>
))}

View File

@@ -1,5 +1,5 @@
.calendar-new__container {
max-width: 32rem;
max-width: 38rem;
}
.calendar-new__select {

View File

@@ -1,9 +1,19 @@
.maps__container {
max-width: 32rem;
max-width: 38rem;
}
.maps__pool-meta {
display: flex;
align-items: center;
justify-content: space-between;
}
.maps__pool-info {
font-size: var(--fonts-xxs);
font-size: var(--fonts-xs);
font-weight: var(--bold);
}
.maps__pool-info a {
font-weight: var(--semi-bold);
}
@@ -49,29 +59,27 @@
.maps__mode-button {
padding: 0;
padding: var(--s-1-5);
border: none;
border-radius: var(--rounded);
background-color: var(--bg-darker);
padding: var(--s-1);
border: 2px solid var(--bg-darker);
border-radius: var(--rounded-full);
background-color: transparent;
color: var(--theme);
opacity: 1 !important;
outline: initial;
}
.maps__mode-button.selected {
background-color: var(--theme-very-transparent);
border: 2px solid transparent;
background-color: var(--bg-mode-active);
}
.maps__stage-image {
border-radius: var(--rounded);
}
.maps__mode {
filter: grayscale(100%);
}
.maps__mode.selected {
filter: unset;
.maps__mode:not(.selected) {
filter: var(--inactive-image-filter);
opacity: 0.6;
}
.maps__map-list-creator {
@@ -96,3 +104,9 @@
font-weight: var(--semi-bold);
margin-block-start: var(--s-4);
}
.maps__mode-abbr {
color: var(--text-lighter);
font-weight: var(--bold);
text-decoration: none;
}

View File

@@ -126,6 +126,10 @@
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.flex-wrap {
flex-wrap: wrap;
}

View File

@@ -7,6 +7,7 @@ html {
--bg-darker-transparent: hsla(202deg 90% 90% / 65%);
--bg-ability: rgb(3 6 7);
--bg-badge: #000;
--bg-mode-active: hsl(255deg 66.7% 50% / 40%);
--abilities-button-bg: hsl(237deg 32% 30%);
--badge-text: rgb(255 255 255 / 95%);
--border: hsl(237deg 100% 86%);
@@ -31,6 +32,7 @@ html {
--theme-semi-transparent-vibrant: hsl(255deg 100% 81% / 75%);
--theme-secondary: hsl(85deg 66.7% 55.3%);
--rounded: 16px;
--rounded-full: 200px;
--rounded-sm: 10px;
--fonts-xl: 1.5rem;
--fonts-lg: 1.2rem;
@@ -73,6 +75,7 @@ html {
--s-96: 2rem;
--sparse: 0.4px;
--label-margin: var(--s-1);
--inactive-image-filter: grayscale(100%) brightness(30%);
}
html.dark {
@@ -84,6 +87,7 @@ html.dark {
--bg-darker-transparent: hsla(237.3deg 42.3% 26.6% / 90%);
--bg-ability: rgb(17 19 43);
--bg-badge: #000;
--bg-mode-active: var(--theme-transparent);
--abilities-button-bg: hsl(237.3deg 42.3% 26.6%);
--border: hsl(237.3deg 42.3% 45.6%);
--button-text: rgb(0 0 0 / 85%);
@@ -104,6 +108,7 @@ html.dark {
--theme-transparent-vibrant: hsl(255deg 78% 65% / 54%);
--theme-semi-transparent-vibrant: hsl(255deg 78% 65% / 75%);
--theme-secondary: hsl(85deg 66.7% 55.3%);
--inactive-image-filter: grayscale(100%) brightness(130%);
}
html.dark .light-mode-only {

View File

@@ -1,5 +1,11 @@
import slugify from "slugify";
import type { Badge, GearType, MapPoolMap, User } from "~/db/types";
import type {
Badge,
CalendarEvent,
GearType,
MapPoolMap,
User,
} from "~/db/types";
import type { ModeShort, weaponCategories } from "~/modules/in-game-lists";
import type {
Ability,
@@ -74,6 +80,8 @@ export const calendarReportWinnersPage = (eventId: number) =>
`/calendar/${eventId}/report-winners`;
export const mapsPage = (eventId?: MapPoolMap["calendarEventId"]) =>
`/maps${eventId ? `?eventId=${eventId}` : ""}`;
export const readonlyMapsPage = (eventId: CalendarEvent["id"]) =>
`/maps?readonly&eventId=${eventId}`;
export const articlePage = (slug: string) => `/a/${slug}`;
export const analyzerPage = (args?: {
weaponId: MainWeaponId;

View File

@@ -34,6 +34,8 @@
"actions.delete": "Löschen",
"actions.loadMore": "Mehr laden",
"actions.close": "Schließen",
"actions.clear": "Leeren",
"actions.selectAll": "Alle auswählen",
"maps.createMapList": "Arenen-Liste erstellen",
"maps.halfSz": "50% Herrschaft",

View File

@@ -15,5 +15,10 @@
"MODE_SHORT_SZ": "HS",
"MODE_SHORT_TC": "TK",
"MODE_SHORT_RM": "OG",
"MODE_SHORT_CB": "MC"
"MODE_SHORT_CB": "MC",
"MODE_LONG_TW": "Revierkampf",
"MODE_LONG_SZ": "Herrschaft",
"MODE_LONG_TC": "Turmkommando",
"MODE_LONG_RM": "Operation Goldfisch",
"MODE_LONG_CB": "Muschelchaos"
}

View File

@@ -36,6 +36,8 @@
"actions.loadMore": "Load more",
"actions.copyToClipboard": "Copy to clipboard",
"actions.close": "Close",
"actions.clear": "Clear",
"actions.selectAll": "Select All",
"maps.createMapList": "Create map list",
"maps.halfSz": "50% SZ",

View File

@@ -15,5 +15,10 @@
"MODE_SHORT_SZ": "SZ",
"MODE_SHORT_TC": "TC",
"MODE_SHORT_RM": "RM",
"MODE_SHORT_CB": "CB"
"MODE_SHORT_CB": "CB",
"MODE_LONG_TW": "Turf War",
"MODE_LONG_SZ": "Splat Zones",
"MODE_LONG_TC": "Tower Control",
"MODE_LONG_RM": "Rainmaker",
"MODE_LONG_CB": "Clam Blitz"
}