mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-13 14:46:10 -05:00
Map planner ranges (#2986)
This commit is contained in:
@@ -159,7 +159,7 @@ export interface WeaponRangeResult {
|
||||
trajectory?: TrajectoryPoint[];
|
||||
}
|
||||
|
||||
function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult {
|
||||
export function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult {
|
||||
const category = getWeaponCategoryName(weaponId);
|
||||
|
||||
if (!category) {
|
||||
|
||||
@@ -28,15 +28,23 @@ import {
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
LogOut,
|
||||
Radius,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getWeaponRange } from "~/features/comp-analyzer/core/weapon-range";
|
||||
import { useTheme } from "~/features/theme/core/provider";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
weaponCategories,
|
||||
@@ -53,12 +61,16 @@ import {
|
||||
} from "~/utils/urls";
|
||||
import { LinkButton, SendouButton } from "../../../components/elements/Button";
|
||||
import { Image } from "../../../components/Image";
|
||||
import type { StageBackgroundStyle } from "../plans-types";
|
||||
import styles from "./Planner.module.css";
|
||||
|
||||
const DROPPED_IMAGE_SIZE_PX = 45;
|
||||
const BACKGROUND_WIDTH = 1127;
|
||||
const BACKGROUND_HEIGHT = 634;
|
||||
const GAME_UNITS_TO_PX: Record<"MINI" | "OVER", number> = {
|
||||
MINI: 4.4,
|
||||
OVER: 8.4,
|
||||
};
|
||||
const MAIN_WEAPON_URL_PATTERN = /main-weapons-outlined\/(\d+)/;
|
||||
|
||||
export default function Planner() {
|
||||
const { t, i18n } = useTranslation(["common"]);
|
||||
@@ -70,6 +82,11 @@ export default function Planner() {
|
||||
const [imgOutlined, setImgOutlined] = React.useState(false);
|
||||
const [topCollapsed, setTopCollapsed] = React.useState(false);
|
||||
const [weaponsCollapsed, setWeaponsCollapsed] = React.useState(false);
|
||||
const [rangesVisible, setRangesVisible] = React.useState(false);
|
||||
const [backgroundStyle, setBackgroundStyle] = React.useState<"MINI" | "OVER">(
|
||||
"MINI",
|
||||
);
|
||||
const rangeCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const [activeDragItem, setActiveDragItem] = React.useState<{
|
||||
src: string;
|
||||
previewPath: string;
|
||||
@@ -200,11 +217,103 @@ export default function Planner() {
|
||||
handleAddWeaponAtPosition(src, [pagePoint.x, pagePoint.y]);
|
||||
};
|
||||
|
||||
const handleRangeToggle = () => {
|
||||
if (!editor) return;
|
||||
|
||||
if (rangesVisible) {
|
||||
rangeCleanupRef.current?.();
|
||||
rangeCleanupRef.current = null;
|
||||
removeRangeCircles(editor);
|
||||
setRangesVisible(false);
|
||||
} else {
|
||||
const gameUnitsToPx = GAME_UNITS_TO_PX[backgroundStyle];
|
||||
removeRangeCircles(editor);
|
||||
for (const shape of editor.getCurrentPageShapes()) {
|
||||
createRangeCircleForShape(editor, shape, gameUnitsToPx);
|
||||
}
|
||||
|
||||
const unsubCreate = editor.sideEffects.registerAfterCreateHandler(
|
||||
"shape",
|
||||
(shape) => {
|
||||
if (shape.meta.isRangeCircle) return;
|
||||
createRangeCircleForShape(editor, shape, gameUnitsToPx);
|
||||
},
|
||||
);
|
||||
|
||||
const unsubChange = editor.sideEffects.registerAfterChangeHandler(
|
||||
"shape",
|
||||
(_prev, next) => {
|
||||
if (next.meta.isRangeCircle) return;
|
||||
|
||||
const rangeCircles = editor
|
||||
.getCurrentPageShapes()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.meta.isRangeCircle === true &&
|
||||
s.meta.weaponShapeId === next.id,
|
||||
);
|
||||
if (rangeCircles.length === 0) return;
|
||||
|
||||
const centerX = next.x + (next.props as { w: number }).w / 2;
|
||||
const centerY = next.y + (next.props as { h: number }).h / 2;
|
||||
|
||||
for (const rangeCircle of rangeCircles) {
|
||||
const radiusPx = (rangeCircle.props as { w: number }).w / 2;
|
||||
editor.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
isLocked: false,
|
||||
});
|
||||
editor.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
x: centerX - radiusPx,
|
||||
y: centerY - radiusPx,
|
||||
isLocked: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const unsubDelete = editor.sideEffects.registerAfterDeleteHandler(
|
||||
"shape",
|
||||
(shape) => {
|
||||
if (shape.meta.isRangeCircle) return;
|
||||
|
||||
const rangeCircles = editor
|
||||
.getCurrentPageShapes()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.meta.isRangeCircle === true &&
|
||||
s.meta.weaponShapeId === shape.id,
|
||||
);
|
||||
if (rangeCircles.length === 0) return;
|
||||
|
||||
for (const rangeCircle of rangeCircles) {
|
||||
editor.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
isLocked: false,
|
||||
});
|
||||
}
|
||||
editor.deleteShapes(rangeCircles);
|
||||
},
|
||||
);
|
||||
|
||||
rangeCleanupRef.current = () => {
|
||||
unsubCreate();
|
||||
unsubChange();
|
||||
unsubDelete();
|
||||
};
|
||||
setRangesVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddBackgroundImage = React.useCallback(
|
||||
(urlArgs: {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
style: StageBackgroundStyle;
|
||||
style: "MINI" | "OVER";
|
||||
}) => {
|
||||
if (!editor) return;
|
||||
|
||||
@@ -225,6 +334,10 @@ export default function Planner() {
|
||||
});
|
||||
|
||||
editor.zoomToFit();
|
||||
rangeCleanupRef.current?.();
|
||||
rangeCleanupRef.current = null;
|
||||
setRangesVisible(false);
|
||||
setBackgroundStyle(urlArgs.style);
|
||||
},
|
||||
[editor, handleAddImage],
|
||||
);
|
||||
@@ -293,6 +406,7 @@ export default function Planner() {
|
||||
outlined={imgOutlined}
|
||||
setImgOutlined={setImgOutlined}
|
||||
/>
|
||||
<RangeToggle active={rangesVisible} onToggle={handleRangeToggle} />
|
||||
<WeaponImageSelector />
|
||||
</div>
|
||||
<button
|
||||
@@ -357,6 +471,7 @@ function OutlineToggle({
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
onPress={handleClick}
|
||||
icon={<Square />}
|
||||
className={clsx(
|
||||
styles.outlineToggleButton,
|
||||
outlined && styles.outlineToggleButtonOutlined,
|
||||
@@ -367,6 +482,30 @@ function OutlineToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function RangeToggle({
|
||||
active,
|
||||
onToggle,
|
||||
}: {
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
return (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
onPress={onToggle}
|
||||
icon={<Radius />}
|
||||
className={clsx(
|
||||
styles.outlineToggleButton,
|
||||
active && styles.outlineToggleButtonOutlined,
|
||||
)}
|
||||
>
|
||||
{t("common:plans.ranges")}
|
||||
</SendouButton>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableWeaponButton({
|
||||
id,
|
||||
src,
|
||||
@@ -529,14 +668,15 @@ function StageBackgroundSelector({
|
||||
onAddBackground: (args: {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
style: StageBackgroundStyle;
|
||||
style: "MINI" | "OVER";
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
const [stageId, setStageId] = React.useState<StageId>(stageIds[0]);
|
||||
const [mode, setMode] = React.useState<ModeShort>("SZ");
|
||||
const [backgroundStyle, setBackgroundStyle] =
|
||||
React.useState<StageBackgroundStyle>("MINI");
|
||||
const [backgroundStyle, setBackgroundStyle] = React.useState<"MINI" | "OVER">(
|
||||
"MINI",
|
||||
);
|
||||
|
||||
const handleStageIdChange = (stageId: StageId) => {
|
||||
setStageId(stageId);
|
||||
@@ -576,9 +716,7 @@ function StageBackgroundSelector({
|
||||
<select
|
||||
className="w-max"
|
||||
value={backgroundStyle}
|
||||
onChange={(e) =>
|
||||
setBackgroundStyle(e.target.value as StageBackgroundStyle)
|
||||
}
|
||||
onChange={(e) => setBackgroundStyle(e.target.value as "MINI" | "OVER")}
|
||||
>
|
||||
{(["MINI", "OVER"] as const).map((style) => {
|
||||
return (
|
||||
@@ -634,3 +772,110 @@ function ourLanguageToTldrawLanguage(ourLanguageUserSelected: string) {
|
||||
logger.error(`No tldraw language found for: ${ourLanguageUserSelected}`);
|
||||
return "en";
|
||||
}
|
||||
|
||||
function extractMainWeaponIdFromSrc(src: string): MainWeaponId | null {
|
||||
const match = src.match(MAIN_WEAPON_URL_PATTERN);
|
||||
if (!match) return null;
|
||||
|
||||
const id = Number(match[1]);
|
||||
if (!mainWeaponIds.includes(id as MainWeaponId)) return null;
|
||||
|
||||
return id as MainWeaponId;
|
||||
}
|
||||
|
||||
function createRangeCircleForShape(
|
||||
editor: Editor,
|
||||
shape: ReturnType<Editor["getCurrentPageShapes"]>[number],
|
||||
gameUnitsToPx: number,
|
||||
) {
|
||||
if (shape.type !== "image") return;
|
||||
|
||||
const assetId = (shape.props as { assetId?: string }).assetId;
|
||||
if (!assetId) return;
|
||||
|
||||
const asset = editor.getAsset(assetId as TLAssetId);
|
||||
if (!asset || asset.type !== "image" || !asset.props.src) return;
|
||||
|
||||
const weaponId = extractMainWeaponIdFromSrc(asset.props.src);
|
||||
if (!weaponId) return;
|
||||
|
||||
const rangeResult = getWeaponRange(weaponId);
|
||||
if (rangeResult.rangeType === "unsupported" || rangeResult.range <= 0) return;
|
||||
|
||||
const centerX = shape.x + (shape.props as { w: number }).w / 2;
|
||||
const centerY = shape.y + (shape.props as { h: number }).h / 2;
|
||||
|
||||
if (typeof rangeResult.blastRadius === "number") {
|
||||
createCircle(editor, {
|
||||
centerX,
|
||||
centerY,
|
||||
radiusPx: (rangeResult.range + rangeResult.blastRadius) * gameUnitsToPx,
|
||||
color: "blue",
|
||||
weaponShapeId: shape.id,
|
||||
});
|
||||
}
|
||||
|
||||
createCircle(editor, {
|
||||
centerX,
|
||||
centerY,
|
||||
radiusPx: rangeResult.range * gameUnitsToPx,
|
||||
color: "red",
|
||||
weaponShapeId: shape.id,
|
||||
});
|
||||
|
||||
editor.bringToFront([shape.id]);
|
||||
}
|
||||
|
||||
function createCircle(
|
||||
editor: Editor,
|
||||
{
|
||||
centerX,
|
||||
centerY,
|
||||
radiusPx,
|
||||
color,
|
||||
weaponShapeId,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
radiusPx: number;
|
||||
color: "red" | "blue";
|
||||
weaponShapeId: TLShapeId;
|
||||
},
|
||||
) {
|
||||
const diameter = radiusPx * 2;
|
||||
editor.createShape({
|
||||
type: "geo",
|
||||
x: centerX - radiusPx,
|
||||
y: centerY - radiusPx,
|
||||
isLocked: true,
|
||||
opacity: 0.3,
|
||||
props: {
|
||||
geo: "ellipse",
|
||||
w: diameter,
|
||||
h: diameter,
|
||||
color,
|
||||
fill: "solid",
|
||||
dash: "solid",
|
||||
size: "s",
|
||||
},
|
||||
meta: { isRangeCircle: true, weaponShapeId },
|
||||
});
|
||||
}
|
||||
|
||||
function removeRangeCircles(editor: Editor) {
|
||||
const shapes = editor.getCurrentPageShapes();
|
||||
const rangeShapes = shapes.filter(
|
||||
(shape) => shape.meta.isRangeCircle === true,
|
||||
);
|
||||
|
||||
if (rangeShapes.length === 0) return;
|
||||
|
||||
for (const rangeShape of rangeShapes) {
|
||||
editor.updateShape({
|
||||
id: rangeShape.id,
|
||||
type: rangeShape.type,
|
||||
isLocked: false,
|
||||
});
|
||||
}
|
||||
editor.deleteShapes(rangeShapes);
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
"plans.bgStyle.OVER": "Set ovenfra",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "af {{author}}",
|
||||
"theme.light": "Lyst",
|
||||
"theme.dark": "Mørkt",
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
"plans.bgStyle.OVER": "Vogelperspektive",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "von {{author}}",
|
||||
"theme.light": "Hell",
|
||||
"theme.dark": "Dunkel",
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
"plans.bgStyle.OVER": "Overhead",
|
||||
"plans.bgStyle.MINI": "Minimap",
|
||||
"plans.adder.objective": "Objective",
|
||||
"plans.ranges": "Ranges",
|
||||
"articles.by": "by {{author}}",
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"plans.bgStyle.OVER": "Vista aérea",
|
||||
"plans.bgStyle.MINI": "Minimapa",
|
||||
"plans.adder.objective": "Objetivo",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "por {{author}}",
|
||||
"theme.light": "Claro",
|
||||
"theme.dark": "Oscuro",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"plans.bgStyle.OVER": "Vista aérea",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "por {{author}}",
|
||||
"theme.light": "Claro",
|
||||
"theme.dark": "Oscuro",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"plans.bgStyle.OVER": "Vue de dessus",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "par {{author}}",
|
||||
"theme.light": "Clair",
|
||||
"theme.dark": "Sombre",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"plans.bgStyle.OVER": "Vue de dessus",
|
||||
"plans.bgStyle.MINI": "Minimap",
|
||||
"plans.adder.objective": "Objectif",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "par {{author}}",
|
||||
"theme.light": "Clair",
|
||||
"theme.dark": "Sombre",
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
"plans.bgStyle.OVER": "מבט על",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "מאת {{author}}",
|
||||
"theme.light": "בהיר",
|
||||
"theme.dark": "חשוך",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"plans.bgStyle.OVER": "Pianta",
|
||||
"plans.bgStyle.MINI": "Minimappa",
|
||||
"plans.adder.objective": "Obiettivo",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "da {{author}}",
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"plans.bgStyle.OVER": "頭上マップ",
|
||||
"plans.bgStyle.MINI": "ミニマップ",
|
||||
"plans.adder.objective": "目的",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "作者: {{author}}",
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"plans.bgStyle.OVER": "전체",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "{{author}} 작성",
|
||||
"theme.light": "라이트",
|
||||
"theme.dark": "다크",
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
"plans.bgStyle.OVER": "",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "",
|
||||
"theme.light": "",
|
||||
"theme.dark": "",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"plans.bgStyle.OVER": "Overhead",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "napisane przez {{author}}",
|
||||
"theme.light": "Jasny",
|
||||
"theme.dark": "Ciemny",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"plans.bgStyle.OVER": "Visão aérea",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "por/pela {{author}}",
|
||||
"theme.light": "Claro",
|
||||
"theme.dark": "Escuro",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"plans.bgStyle.OVER": "Вид сверху",
|
||||
"plans.bgStyle.MINI": "Миникарта",
|
||||
"plans.adder.objective": "Цель",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "от {{author}}",
|
||||
"theme.light": "Светлая",
|
||||
"theme.dark": "Тёмная",
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"plans.bgStyle.OVER": "俯视图",
|
||||
"plans.bgStyle.MINI": "",
|
||||
"plans.adder.objective": "",
|
||||
"plans.ranges": "",
|
||||
"articles.by": "作者 {{author}}",
|
||||
"theme.light": "浅色模式",
|
||||
"theme.dark": "深色模式",
|
||||
|
||||
Reference in New Issue
Block a user