From c0395cc1bcc29eee3b96f8a66c4219f09f84c227 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sat, 18 Apr 2026 17:31:08 +0300
Subject: [PATCH] Map planner ranges (#2986)
---
.../comp-analyzer/core/weapon-range.ts | 2 +-
.../map-planner/components/Planner.tsx | 263 +++++++++++++++++-
locales/da/common.json | 1 +
locales/de/common.json | 1 +
locales/en/common.json | 1 +
locales/es-ES/common.json | 1 +
locales/es-US/common.json | 1 +
locales/fr-CA/common.json | 1 +
locales/fr-EU/common.json | 1 +
locales/he/common.json | 1 +
locales/it/common.json | 1 +
locales/ja/common.json | 1 +
locales/ko/common.json | 1 +
locales/nl/common.json | 1 +
locales/pl/common.json | 1 +
locales/pt-BR/common.json | 1 +
locales/ru/common.json | 1 +
locales/zh/common.json | 1 +
18 files changed, 271 insertions(+), 10 deletions(-)
diff --git a/app/features/comp-analyzer/core/weapon-range.ts b/app/features/comp-analyzer/core/weapon-range.ts
index d94fa91f0..073addb13 100644
--- a/app/features/comp-analyzer/core/weapon-range.ts
+++ b/app/features/comp-analyzer/core/weapon-range.ts
@@ -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) {
diff --git a/app/features/map-planner/components/Planner.tsx b/app/features/map-planner/components/Planner.tsx
index fec1133b0..9825679e3 100644
--- a/app/features/map-planner/components/Planner.tsx
+++ b/app/features/map-planner/components/Planner.tsx
@@ -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}
/>
+
}
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 (
+ }
+ className={clsx(
+ styles.outlineToggleButton,
+ active && styles.outlineToggleButtonOutlined,
+ )}
+ >
+ {t("common:plans.ranges")}
+
+ );
+}
+
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(stageIds[0]);
const [mode, setMode] = React.useState("SZ");
- const [backgroundStyle, setBackgroundStyle] =
- React.useState("MINI");
+ const [backgroundStyle, setBackgroundStyle] = React.useState<"MINI" | "OVER">(
+ "MINI",
+ );
const handleStageIdChange = (stageId: StageId) => {
setStageId(stageId);
@@ -576,9 +716,7 @@ function StageBackgroundSelector({