Map Planner (#1145)

* Initial

* Add images

* Tweaks

* Proper sized bg image and weapons

* Stage bg picker

* Outlined weapon images for planner

* First version

* Lint

* Add alt and title to weapon
This commit is contained in:
Kalle 2022-11-23 17:38:38 +02:00 committed by GitHub
parent 02516e26bd
commit aea7406586
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
200 changed files with 2804 additions and 36 deletions

252
app/components/Planner.tsx Normal file
View File

@ -0,0 +1,252 @@
import type { TDImageAsset } from "@tldraw/tldraw";
import {
Tldraw,
ColorStyle,
type TldrawApp,
TDShapeType,
TDAssetType,
} from "@tldraw/tldraw";
import clsx from "clsx";
import * as React from "react";
import { useForceRefreshOnMount } from "~/hooks/useForceRefresh";
import { useTranslation } from "~/hooks/useTranslation";
import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists";
import { stageIds } from "~/modules/in-game-lists";
import { mainWeaponIds } from "~/modules/in-game-lists";
import { modesShort } from "~/modules/in-game-lists/modes";
import {
mainWeaponImageUrl,
modeImageUrl,
outlinedMainWeaponImageUrl,
stageMinimapImageUrlWithEnding,
TLDRAW_URL,
} from "~/utils/urls";
import { Button } from "./Button";
import { Image } from "./Image";
import { nanoid } from "nanoid";
import randomInt from "just-random-integer";
import type { LanguageCode } from "~/modules/i18n";
export default function Planner() {
const { t } = useTranslation(["common", "weapons"]);
const { i18n } = useTranslation();
const appRef = React.useRef<TldrawApp>();
const app = appRef.current!;
useForceRefreshOnMount();
const handleMount = React.useCallback(
(mountedApp: TldrawApp) => {
appRef.current = mountedApp;
mountedApp.setSetting(
"language",
ourLanguageToTldrawLanguage(i18n.language)
);
mountedApp.style({ color: ColorStyle.Red });
},
[i18n]
);
const handleAddImage = React.useCallback(
({
src,
size,
isLocked,
point,
cb,
}: {
src: string;
size: number[];
isLocked: boolean;
point: number[];
cb?: () => void;
}) => {
if (!app) return;
const asset: TDImageAsset = {
id: src,
type: TDAssetType.Image,
fileName: "img",
src,
size,
};
// tldraw creator:
// "So image shapes in tldraw work like this: we add an asset to the app.assets table, then we reference that asset in the shape object itself.
// This lets us have multiple copies of an image on the canvas without having all of those take up memory individually"
app.insertContent({
assets: [asset],
shapes: [],
});
app.createShapes({
id: nanoid(),
type: TDShapeType.Image,
assetId: src,
size,
isAspectRatioLocked: true,
isLocked,
point,
});
cb?.();
},
[app]
);
const handleAddWeapon = React.useCallback(
(weaponId: MainWeaponId) => {
handleAddImage({
src: `${outlinedMainWeaponImageUrl(weaponId)}.png`,
size: [45, 45],
isLocked: false,
point: [randomInt(250, 1000), randomInt(250, 750)],
cb: () => app.selectTool("select"),
});
},
[app, handleAddImage]
);
const handleAddBackgroundImage = React.useCallback(
({ stageId, modeShort }: { stageId: StageId; modeShort: ModeShort }) => {
app.resetDocument();
handleAddImage({
src: stageMinimapImageUrlWithEnding({ stageId, modeShort }),
size: [1600, 900],
isLocked: true,
point: [65, 20],
});
},
[app, handleAddImage]
);
return (
<>
<StageBackgroundSelector onAddBackground={handleAddBackgroundImage} />
<div className="plans__weapons-section">
{mainWeaponIds.map((weaponId) => {
return (
<Button
key={weaponId}
variant="minimal"
onClick={() => handleAddWeapon(weaponId)}
>
<Image
alt={t(`weapons:MAIN_${weaponId}`)}
title={t(`weapons:MAIN_${weaponId}`)}
path={mainWeaponImageUrl(weaponId)}
width={36}
height={36}
/>
</Button>
);
})}
</div>
<div className="plans__powered-by">
<a href={TLDRAW_URL} target="_blank" rel="noreferrer">
{t("common:plans.poweredBy", { name: "tldraw" })}
</a>
</div>
<Tldraw showMultiplayerMenu={false} onMount={handleMount} />
</>
);
}
function StageBackgroundSelector({
onAddBackground,
}: {
onAddBackground: ({
stageId,
modeShort,
}: {
stageId: StageId;
modeShort: ModeShort;
}) => void;
}) {
const { t } = useTranslation(["game-misc", "common"]);
const [stageId, setStageId] = React.useState<StageId>(stageIds[0]);
const [selectedMode, setSelectedMode] = React.useState<ModeShort>("SZ");
return (
<div className="plans__top-section">
<select
className="w-max"
value={stageId}
onChange={(e) => setStageId(Number(e.target.value) as StageId)}
aria-label="Select stage"
>
{stageIds.map((stageId) => {
return (
<option value={stageId} key={stageId}>
{t(`game-misc:STAGE_${stageId}`)}
</option>
);
})}
</select>
<div className="plans__mode-buttons">
{modesShort.map((mode) => {
const selected = mode === selectedMode;
return (
<button
key={mode}
className={clsx("plans__mode-button", "outline-theme", {
selected,
})}
onClick={() => setSelectedMode(mode)}
type="button"
title={t(`game-misc:MODE_LONG_${mode}`)}
aria-pressed={selected}
>
<Image
className={clsx("plans__mode-img", {
selected,
})}
alt={t(`game-misc:MODE_LONG_${mode}`)}
path={modeImageUrl(mode)}
width={20}
height={20}
/>
</button>
);
})}
</div>
<Button
tiny
onClick={() => onAddBackground({ modeShort: selectedMode, stageId })}
className="w-max"
>
{t("common:actions.setBg")}
</Button>
</div>
);
}
// when adding new language check from Tldraw codebase what is the matching
// language in TRANSLATIONS constant, or default to english if none found
const ourLanguageToTldrawLanguageMap: Record<LanguageCode, string> = {
"es-US": "es",
"es-ES": "es",
ko: "ko-kr",
nl: "en",
zh: "zh-ch",
// map to itself
da: "da",
de: "de",
en: "en",
fr: "fr",
it: "it",
ja: "ja",
ru: "ru",
};
function ourLanguageToTldrawLanguage(ourLanguageUserSelected: string) {
for (const [ourLanguage, tldrawLanguage] of Object.entries(
ourLanguageToTldrawLanguageMap
)) {
if (ourLanguage === ourLanguageUserSelected) {
return tldrawLanguage;
}
}
console.error(`No tldraw language found for: ${ourLanguageUserSelected}`);
return "en";
}

View File

@ -11,6 +11,11 @@
"url": "object-damage-calculator",
"prefetch": true
},
{
"name": "plans",
"url": "plans",
"prefetch": false
},
{ "name": "calendar", "url": "calendar", "prefetch": false },
{ "name": "maps", "url": "maps", "prefetch": false },
{ "name": "badges", "url": "badges", "prefetch": false },

View File

@ -0,0 +1,9 @@
import * as React from "react";
export function useForceRefreshOnMount() {
const [, setOne] = React.useState(0);
React.useEffect(() => {
setOne(1);
}, []);
}

View File

@ -51,6 +51,8 @@ export const languages = [
},
] as const;
export type LanguageCode = typeof languages[number]["code"];
export const config = {
supportedLngs: languages.map((lang) => lang.code),
fallbackLng: DEFAULT_LANGUAGE,

View File

@ -1,4 +1,4 @@
export { i18nLoader } from "./loader";
export { i18Instance } from "./loader.server";
export { i18next, i18nCookie } from "./i18next.server";
export { DEFAULT_LANGUAGE, languages } from "./config";
export { DEFAULT_LANGUAGE, languages, type LanguageCode } from "./config";

View File

@ -9,6 +9,7 @@ import {
GITHUB_CONTRIBUTORS_URL,
LEAN_TWITTER,
SENDOU_TWITTER_URL,
TWIG_TWITTER,
UBERU_TWITTER,
} from "~/utils/urls";
import { type SendouRouteHandle } from "~/utils/remix";
@ -24,6 +25,8 @@ export const handle: SendouRouteHandle = {
i18n: "contributions",
};
const PROGRAMMERS = ["DoubleCookies", "ElementUser", "remmycat"] as const;
const TRANSLATORS: Array<{
translators: Array<string>;
language: typeof languages[number]["code"];
@ -83,6 +86,7 @@ export default function ContributionsPage() {
</p>
<ul className="mt-2">
<li>
{PROGRAMMERS.join(", ")} -{" "}
<a href={GITHUB_CONTRIBUTORS_URL} target="_blank" rel="noreferrer">
{t("contributions:code")}
</a>
@ -105,6 +109,12 @@ export default function ContributionsPage() {
</a>{" "}
- {t("contributions:uberu")}
</li>
<li>
<a href={TWIG_TWITTER} target="_blank" rel="noreferrer">
Twig
</a>{" "}
- {t("contributions:twig")}
</li>
{TRANSLATORS.map(({ translators, language }) => (
<li key={language}>
{translators.join(", ")} - {t("contributions:translation")} (

View File

@ -24,6 +24,7 @@ import {
mapsPage,
navIconUrl,
objectDamageCalculatorPage,
PLANNER_URL,
plusSuggestionPage,
userPage,
} from "~/utils/urls";
@ -108,6 +109,12 @@ export default function Index() {
description={t("front:maps.description")}
to={mapsPage()}
/>
<FeatureCard
navItem="plans"
title={t("common:pages.plans")}
description={t("front:plans.description")}
to={PLANNER_URL}
/>
</div>
</div>
</Main>

22
app/routes/plans.tsx Normal file
View File

@ -0,0 +1,22 @@
import { lazy, Suspense } from "react";
import type { LinksFunction } from "@remix-run/node";
import styles from "~/styles/plans.css";
import type { SendouRouteHandle } from "~/utils/remix";
export const handle: SendouRouteHandle = {
i18n: ["weapons"],
};
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
const Planner = lazy(() => import("~/components/Planner"));
export default function MapPlannerPage() {
return (
<Suspense fallback={<div className="plans__placeholder" />}>
<Planner />
</Suspense>
);
}

View File

@ -266,7 +266,7 @@ select {
font-size: var(--fonts-sm);
font-weight: 500;
padding-block: 3.5px;
padding-inline: var(--s-3) var(--s-7);
padding-inline: var(--s-3) var(--s-8);
}
/* Temporary solution for issue: https://github.com/Sendouc/sendou.ink/issues/1141 */

92
app/styles/plans.css Normal file
View File

@ -0,0 +1,92 @@
div {
box-shadow: none !important;
}
/** Hacky selector to remove the question mark popup on bottom left */
button[data-state="closed"][aria-haspopup="dialog"] {
display: none;
}
/* stylelint-disable selector-id-pattern */
#TD-MenuPanel {
display: none;
}
/* stylelint-enable selector-id-pattern */
.layout__header {
display: none;
}
.tl-canvas {
background-color: var(--bg);
}
.plans__placeholder {
min-height: 100%;
}
.plans__top-section {
position: fixed;
z-index: 10;
top: 3%;
left: 50%;
display: flex;
align-items: center;
padding: var(--s-3);
border: 1px solid var(--theme-very-transparent);
border-radius: 0 0 var(--rounded) var(--rounded);
background-color: var(--bg-darker);
gap: var(--s-4);
transform: translate(-50%, -42%);
}
.plans__mode-buttons {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
}
.plans__mode-button {
padding: 0;
padding: var(--s-1-5);
border: none;
border-radius: var(--rounded-full);
background-color: transparent;
color: var(--theme);
opacity: 1 !important;
outline: initial;
}
.plans__mode-button.selected {
background-color: var(--bg-mode-active);
}
.plans__mode-img:not(.selected) {
filter: var(--inactive-image-filter);
opacity: 0.6;
}
.plans__weapons-section {
position: fixed;
z-index: 10;
top: 15%;
display: flex;
width: 130px;
flex-wrap: wrap;
justify-content: center;
padding: var(--s-2);
border: 1px solid var(--theme-very-transparent);
border-radius: 0 var(--rounded) var(--rounded) 0;
background: var(--bg-darker);
gap: 2px;
}
.plans__powered-by {
position: fixed;
z-index: 10;
right: 0.5%;
bottom: 0.5%;
color: var(--text-lighter);
font-size: var(--fonts-xxxs);
}

View File

@ -12,8 +12,8 @@ import type {
AbilityWithUnknown,
MainWeaponId,
SpecialWeaponId,
StageId,
SubWeaponId,
StageId,
} from "~/modules/in-game-lists/types";
import type navItems from "~/components/layout/nav-items.json";
import { type AuthErrorCode } from "~/modules/auth";
@ -27,9 +27,11 @@ export const SENDOU_INK_PATREON_URL = "https://patreon.com/sendou";
export const SENDOU_INK_GITHUB_URL = "https://github.com/Sendouc/sendou.ink";
export const GITHUB_CONTRIBUTORS_URL =
"https://github.com/Sendouc/sendou.ink/graphs/contributors";
export const TLDRAW_URL = "https://www.tldraw.com/";
export const BORZOIC_TWITTER = "https://twitter.com/borzoic_";
export const LEAN_TWITTER = "https://twitter.com/LeanYoshi";
export const UBERU_TWITTER = "https://twitter.com/uberu5";
export const TWIG_TWITTER = "https://twitter.com/TwigTheBluePik";
export const ipLabsMaps = (pool: string) =>
`https://maps.iplabs.ink/?3&pool=${pool}`;
@ -44,6 +46,7 @@ export const BUILDS_PAGE = "/builds";
export const CALENDAR_PAGE = "/calendar";
export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop";
export const SEED_URL = "/seed";
export const PLANNER_URL = "/plans";
export const COMMON_PREVIEW_IMAGE = "/img/layout/common-preview.png";
export const ERROR_GIRL_IMAGE_PATH = `/img/layout/error-girl`;
@ -125,6 +128,8 @@ export const weaponCategoryUrl = (
) => `/img/weapon-categories/${category}`;
export const mainWeaponImageUrl = (mainWeaponSplId: MainWeaponId) =>
`/img/main-weapons/${mainWeaponSplId}`;
export const outlinedMainWeaponImageUrl = (mainWeaponSplId: MainWeaponId) =>
`/img/main-weapons-outlined/${mainWeaponSplId}`;
export const subWeaponImageUrl = (subWeaponSplId: SubWeaponId) =>
`/img/sub-weapons/${subWeaponSplId}`;
export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) =>
@ -133,6 +138,13 @@ export const abilityImageUrl = (ability: AbilityWithUnknown) =>
`/img/abilities/${ability}`;
export const modeImageUrl = (mode: ModeShort) => `/img/modes/${mode}`;
export const stageImageUrl = (stageId: StageId) => `/img/stages/${stageId}`;
export const stageMinimapImageUrlWithEnding = ({
stageId,
modeShort,
}: {
stageId: StageId;
modeShort: ModeShort;
}) => `/img/stage-minimaps/${stageId}-${modeShort}.jpeg`;
export function resolveBaseUrl(url: string) {
return new URL(url).host;

2357
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@
"check-translation-jsons": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/check-translation-jsons.ts",
"check-translation-jsons:no-write": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/check-translation-jsons.ts --no-write",
"replace-img-names": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/replace-img-names.ts",
"replace-weapon-names": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/replace-weapon-names.ts",
"remove-bad-custom-urls": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/remove-bad-custom-urls.ts",
"lint:ts": "eslint . --ext .ts,.tsx",
"lint:styles": "stylelint \"app/styles/**/*.css\"",
@ -36,6 +37,7 @@
"@remix-run/node": "^1.7.4",
"@remix-run/react": "^1.7.4",
"@remix-run/serve": "^1.7.4",
"@tldraw/tldraw": "^1.26.2",
"better-sqlite3": "^7.6.2",
"clsx": "^1.2.1",
"countries-list": "^2.6.1",
@ -48,8 +50,10 @@
"i18next-http-backend": "^1.4.4",
"just-capitalize": "^3.1.1",
"just-clone": "^6.1.1",
"just-random-integer": "^4.1.1",
"just-shuffle": "^4.1.1",
"markdown-to-jsx": "^7.1.7",
"nanoid": "^4.0.0",
"node-cron": "3.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",

Binary file not shown.

BIN
public/img/layout/plans.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More