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} /> +