Map planner ranges (#2986)

This commit is contained in:
Kalle
2026-04-18 17:31:08 +03:00
committed by GitHub
parent 2477231628
commit c0395cc1bc
18 changed files with 271 additions and 10 deletions

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -230,6 +230,7 @@
"plans.bgStyle.OVER": "מבט על",
"plans.bgStyle.MINI": "",
"plans.adder.objective": "",
"plans.ranges": "",
"articles.by": "מאת {{author}}",
"theme.light": "בהיר",
"theme.dark": "חשוך",

View File

@@ -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",

View File

@@ -228,6 +228,7 @@
"plans.bgStyle.OVER": "頭上マップ",
"plans.bgStyle.MINI": "ミニマップ",
"plans.adder.objective": "目的",
"plans.ranges": "",
"articles.by": "作者: {{author}}",
"theme.light": "Light",
"theme.dark": "Dark",

View File

@@ -228,6 +228,7 @@
"plans.bgStyle.OVER": "전체",
"plans.bgStyle.MINI": "",
"plans.adder.objective": "",
"plans.ranges": "",
"articles.by": "{{author}} 작성",
"theme.light": "라이트",
"theme.dark": "다크",

View File

@@ -230,6 +230,7 @@
"plans.bgStyle.OVER": "",
"plans.bgStyle.MINI": "",
"plans.adder.objective": "",
"plans.ranges": "",
"articles.by": "",
"theme.light": "",
"theme.dark": "",

View File

@@ -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",

View File

@@ -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",

View File

@@ -231,6 +231,7 @@
"plans.bgStyle.OVER": "Вид сверху",
"plans.bgStyle.MINI": "Миникарта",
"plans.adder.objective": "Цель",
"plans.ranges": "",
"articles.by": "от {{author}}",
"theme.light": "Светлая",
"theme.dark": "Тёмная",

View File

@@ -228,6 +228,7 @@
"plans.bgStyle.OVER": "俯视图",
"plans.bgStyle.MINI": "",
"plans.adder.objective": "",
"plans.ranges": "",
"articles.by": "作者 {{author}}",
"theme.light": "浅色模式",
"theme.dark": "深色模式",