From 954491c07e4d773b0431cf2f2b1cd1f9fef852ab Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:48:59 +0300 Subject: [PATCH] Ability selector redesign with dnd-kit --- app/components/AbilitiesSelector.module.css | 153 +++++- app/components/AbilitiesSelector.tsx | 494 ++++++++++++------ app/components/Ability.module.css | 11 - app/components/Ability.tsx | 27 - app/components/AbilitySlots.test.ts | 208 ++++++++ app/components/AbilitySlots.ts | 126 +++++ ...026-09-29-ability-selector-improvements.md | 11 + 7 files changed, 835 insertions(+), 195 deletions(-) create mode 100644 app/components/AbilitySlots.test.ts create mode 100644 app/components/AbilitySlots.ts create mode 100644 changelog/2026-09-29-ability-selector-improvements.md diff --git a/app/components/AbilitiesSelector.module.css b/app/components/AbilitiesSelector.module.css index d24945ca1..53e65e4b7 100644 --- a/app/components/AbilitiesSelector.module.css +++ b/app/components/AbilitiesSelector.module.css @@ -14,21 +14,168 @@ place-items: center; } -.abilityButtons { +.slot { + position: relative; + display: grid; + border-radius: 50%; + touch-action: manipulation; + transition: + transform 0.1s ease, + opacity 0.2s ease, + filter 0.2s ease; + + &.draggable { + cursor: grab; + user-select: none; + -webkit-touch-callout: none; + } + + &.dimmed { + filter: grayscale(1); + opacity: 0.3; + } + + &.dragSource { + opacity: 0.3; + } + + &.highlighted { + transform: scale(1.15); + } +} + +.ghost { + position: absolute; + display: grid; + border: var(--border-style-high); + border-radius: 50%; + border-right: 0; + border-bottom: 0; + background: var(--color-bg-ability); + animation: ghost-fade-in 0.2s ease; + inset: 0; + pointer-events: none; + + & img { + width: 100%; + height: 100%; + opacity: 0.35; + } +} + +@keyframes ghost-fade-in { + from { + opacity: 0; + } +} + +.palette { + --row-marker-offset: var(--s-4); + --row-marker-width: var(--s-5); + --row-end-padding: var(--s-2); + container-type: inline-size; display: flex; + flex-direction: column; + padding-block: var(--s-2); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); + gap: var(--s-2); +} + +.paletteGroup { + --palette-gap: var(--s-1); + position: relative; + display: flex; + min-inline-size: 0; + padding-block: 0; + padding-inline: calc(var(--row-marker-offset) + var(--row-marker-width)) + var(--row-end-padding); + border: 0; + border-radius: 0; + margin: 0; + background: none; flex-wrap: wrap; justify-content: center; - gap: var(--s-1); - touch-action: manipulation; + gap: var(--palette-gap); + + & + .paletteGroup { + padding-block-start: var(--s-2); + border-block-start: var(--border-style); + + & .rowMarker { + top: var(--s-2); + } + } +} + +@container (width < 17rem) { + .paletteGroup { + --row-marker-offset: var(--s-1); + --row-end-padding: var(--s-1); + } + + .abilityButton img { + width: 28px; + height: 28px; + } +} + +.rowMarker { + position: absolute; + display: grid; + width: var(--row-marker-width); + bottom: 0; + left: var(--row-marker-offset); + color: var(--color-text-high); + font-size: var(--font-lg); + font-weight: var(--weight-extra); + place-items: center; + top: 0; } .abilityButton { + position: relative; padding: var(--s-0-5); border: var(--border-style); border-radius: 50%; background-color: var(--color-bg-ability); + touch-action: manipulation; + user-select: none; + -webkit-touch-callout: none; + transition: + transform 0.1s ease, + opacity 0.1s ease, + filter 0.1s ease; + + @media (hover: hover) { + &:hover:not(.unplaceable) { + transform: scale(1.15); + } + } + + &::after { + content: ""; + position: absolute; + inset: calc(var(--palette-gap) / -2 - var(--border-width)); + } &.isDragging { box-shadow: 0 0 100px inset rgb(255 255 255 / 25%); } + + &.unplaceable { + filter: grayscale(1); + opacity: 0.5; + cursor: not-allowed; + } +} + +.dragOverlay { + cursor: grabbing; + transition: opacity 0.1s ease; + + &.dropRemoves { + opacity: 0.4; + } } diff --git a/app/components/AbilitiesSelector.tsx b/app/components/AbilitiesSelector.tsx index ac49bbf61..b25855f35 100644 --- a/app/components/AbilitiesSelector.tsx +++ b/app/components/AbilitiesSelector.tsx @@ -1,13 +1,60 @@ +import { + type CollisionDetection, + DndContext, + type DragEndEvent, + type DragOverEvent, + DragOverlay, + type DragStartEvent, + MouseSensor, + pointerWithin, + TouchSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; import clsx from "clsx"; +import { Layers } from "lucide-react"; import * as React from "react"; +import { useTranslation } from "react-i18next"; import { abilities } from "~/modules/in-game-lists/abilities"; -import type { BuildAbilitiesTupleWithUnknown } from "~/modules/in-game-lists/types"; -import { invariant } from "~/utils/invariant"; +import type { + Ability as AbilityName, + AbilityType, + AbilityWithUnknown, + BuildAbilitiesTupleWithUnknown, +} from "~/modules/in-game-lists/types"; import { abilityImageUrl } from "~/utils/urls"; import styles from "./AbilitiesSelector.module.css"; import { Ability } from "./Ability"; +import * as AbilitySlots from "./AbilitySlots"; import { Image } from "./Image"; +const SLOTS_DROPPABLE_ID = "slots"; +const PREVIEW_SHOW_DELAY_MS = 150; +const PREVIEW_HIDE_DELAY_MS = 100; + +const STACKABLE_GROUP: PaletteGroup = { + type: "STACKABLE", + label: "Stackable abilities", +}; + +const MAIN_ONLY_GROUPS: PaletteGroup[] = [ + { type: "HEAD_MAIN_ONLY", label: "Headgear-only abilities" }, + { type: "CLOTHES_MAIN_ONLY", label: "Clothing-only abilities" }, + { type: "SHOES_MAIN_ONLY", label: "Shoes-only abilities" }, +]; + +interface PaletteGroup { + type: AbilityType; + label: string; +} + +interface DragData { + ability: AbilityName; + from?: AbilitySlots.Slot; +} + interface AbilitiesSelectorProps { selectedAbilities: BuildAbilitiesTupleWithUnknown; onChange: (newAbilities: BuildAbilitiesTupleWithUnknown) => void; @@ -18,185 +65,324 @@ export function AbilitiesSelector({ onChange, }: AbilitiesSelectorProps) { const [, startTransition] = React.useTransition(); + const [dragging, setDragging] = React.useState(null); + const [dropRemoves, setDropRemoves] = React.useState(false); + const [previewAbility, setPreviewAbility] = + React.useState(null); + const previewTimeoutRef = React.useRef>(null); + const suppressClickRef = React.useRef(false); - const onSlotClick = ({ - rowI, - abilityI, - }: { - rowI: number; - abilityI: number; - }) => { - const abilitiesClone = JSON.parse( - JSON.stringify(selectedAbilities), - ) as BuildAbilitiesTupleWithUnknown; + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + ); - const row = abilitiesClone[rowI]; - invariant(row); - invariant(row.length === 4); + const previewSlot = + !dragging && previewAbility + ? AbilitySlots.firstEmptyValidSlot(selectedAbilities, previewAbility) + : null; - // no need to trigger a rerender - if (row[abilityI] === "UNKNOWN") return; + const isAllowedAt = (slot: AbilitySlots.Slot) => { + if (dragging?.from) { + return AbilitySlots.canMove(selectedAbilities, dragging.from, slot); + } - row[abilityI] = "UNKNOWN"; - - onChange(abilitiesClone); + const ability = dragging?.ability ?? previewAbility; + return !ability || AbilitySlots.canPlaceAt(ability, slot); }; - const onButtonClick = (ability: (typeof abilities)[number]) => { + + // switching between abilities is instant, only showing and hiding the preview is delayed + const schedulePreview = ( + ability: AbilityName | null, + { immediate = false } = {}, + ) => { + if (previewTimeoutRef.current) clearTimeout(previewTimeoutRef.current); + + if (immediate || (ability && previewAbility)) { + setPreviewAbility(ability); + return; + } + + previewTimeoutRef.current = setTimeout( + () => setPreviewAbility(ability), + ability ? PREVIEW_SHOW_DELAY_MS : PREVIEW_HIDE_DELAY_MS, + ); + }; + + const handleSlotClick = (slot: AbilitySlots.Slot) => { + if (suppressClickRef.current) return; + + onChange(AbilitySlots.remove(selectedAbilities, slot)); + }; + + const handlePaletteClick = (ability: AbilityName) => { + if (suppressClickRef.current) return; + startTransition(() => { - onChange(addAbility({ oldAbilities: selectedAbilities, ability })); + onChange(AbilitySlots.add(selectedAbilities, ability)); }); }; - const [draggingAbility, setDraggingAbility] = React.useState< - (typeof abilities)[number] | undefined - >(); - - const onDragStart = - (ability: (typeof abilities)[number]) => (event: React.DragEvent) => { - setDraggingAbility(ability); - event.dataTransfer.setData("text/plain", JSON.stringify(ability)); - }; - - const onDragEnd = () => { - setDraggingAbility(undefined); + const handleDragStart = (event: DragStartEvent) => { + setDragging(event.active.data.current as DragData); + schedulePreview(null, { immediate: true }); }; - const onDrop = - (atRowIndex: number, atAbilityIndex: number) => - (event: React.DragEvent) => { - event.preventDefault(); - const ability = JSON.parse( - event.dataTransfer.getData("text/plain"), - ) as (typeof abilities)[number]; + const handleDragOver = (event: DragOverEvent) => { + const data = event.active.data.current as DragData; + setDropRemoves(Boolean(data.from) && !event.over); + }; + const handleDragEnd = (event: DragEndEvent) => { + const data = event.active.data.current as DragData; + const targetSlot = event.over?.data.current?.slot as + | AbilitySlots.Slot + | undefined; + + stopDragging(); + + if (targetSlot) { onChange( - addAbility({ - oldAbilities: selectedAbilities, - ability, - atRowIndex, - atAbilityIndex, - }), + data.from + ? AbilitySlots.move(selectedAbilities, data.from, targetSlot) + : AbilitySlots.placeAt(selectedAbilities, data.ability, targetSlot), ); - }; + } else if (data.from && !event.over) { + onChange(AbilitySlots.remove(selectedAbilities, data.from)); + } + }; + + // the pointer release that ends a drag also fires a click on the dragged element + const stopDragging = () => { + setDragging(null); + setDropRemoves(false); + suppressClickRef.current = true; + setTimeout(() => { + suppressClickRef.current = false; + }); + }; + + const renderPaletteGroup = (group: PaletteGroup, marker: React.ReactNode) => ( +
+ + {marker} + + {abilities + .filter((ability) => ability.type === group.type) + .map((ability) => ( + handlePaletteClick(ability.name)} + onPreviewChange={(isPreviewing) => + schedulePreview(isPreviewing ? ability.name : null) + } + /> + ))} +
+ ); return ( -
-
- {selectedAbilities.map((row, rowI) => - row.map((ability, abilityI) => ( - onSlotClick({ rowI, abilityI })} - dragStarted={!!draggingAbility} - dropAllowed={canPlaceAbilityAtSlot( - rowI, - abilityI, - draggingAbility, - )} - onDrop={onDrop(rowI, abilityI)} - /> - )), - )} + +
+ + {selectedAbilities.map((row, rowI) => + row.map((ability, abilityI) => { + const slot = { rowI, abilityI }; + + return ( + handleSlotClick(slot)} + /> + ); + }), + )} + +
+ {renderPaletteGroup( + STACKABLE_GROUP, + , + )} + {MAIN_ONLY_GROUPS.map((group, i) => renderPaletteGroup(group, i + 1))} +
-
- {abilities.map((ability) => ( - - ))} -
+ +
+ ) : null} + + + ); +} + +function SlotGrid({ children }: { children: React.ReactNode }) { + const { setNodeRef } = useDroppable({ id: SLOTS_DROPPABLE_ID }); + + return ( +
+ {children}
); } -const canPlaceAbilityAtSlot = ( - rowIndex: number, - abilityIndex: number, - ability?: (typeof abilities)[number], -) => { - if (!ability) { - return false; - } +function AbilitySlot({ + slot, + ability, + isDragging, + isDragSource, + isAllowed, + ghostAbility, + onClick, +}: { + slot: AbilitySlots.Slot; + ability: AbilityWithUnknown; + isDragging: boolean; + isDragSource: boolean; + isAllowed: boolean; + ghostAbility: AbilityName | null; + onClick: () => void; +}) { + const slotKey = `${slot.rowI}-${slot.abilityI}`; + const droppable = useDroppable({ + id: `slot-drop-${slotKey}`, + data: { slot }, + disabled: !isAllowed, + }); + const draggable = useDraggable({ + id: `slot-drag-${slotKey}`, + data: { ability, from: slot }, + disabled: ability === "UNKNOWN", + }); - const legalGearTypeForMain = - rowIndex === 0 - ? "HEAD_MAIN_ONLY" - : rowIndex === 1 - ? "CLOTHES_MAIN_ONLY" - : "SHOES_MAIN_ONLY"; + return ( +
{ + droppable.setNodeRef(node); + draggable.setNodeRef(node); + }} + className={clsx(styles.slot, { + [styles.draggable]: ability !== "UNKNOWN", + [styles.dimmed]: !isAllowed, + [styles.dragSource]: isDragSource, + [styles.highlighted]: isDragging && droppable.isOver, + })} + {...draggable.listeners} + > + + {ghostAbility ? ( + + ) : null} +
+ ); +} - const isMainSlot = abilityIndex === 0; +function PaletteButton({ + ability, + isUnplaceable, + isDragging, + onClick, + onPreviewChange, +}: { + ability: AbilityName; + isUnplaceable: boolean; + isDragging: boolean; + onClick: () => void; + onPreviewChange: (isPreviewing: boolean) => void; +}) { + const { t } = useTranslation(["game-misc"]); + const { setNodeRef, listeners } = useDraggable({ + id: `palette-${ability}`, + data: { ability } satisfies DragData, + }); - if ( - !["STACKABLE", legalGearTypeForMain].includes(ability.type) && - isMainSlot - ) { - // Can't put this type of gear in main slot - return false; - } + const name = t(`game-misc:ABILITY_${ability}`); - if (!isMainSlot && ability.type !== "STACKABLE") { - // Can't put main slot only gear to sub slots - return false; - } - return true; + return ( + + ); +} + +const slotsFirstCollisionDetection: CollisionDetection = (args) => { + const collisions = pointerWithin(args); + const slotCollision = collisions.find( + (collision) => collision.id !== SLOTS_DROPPABLE_ID, + ); + + return slotCollision ? [slotCollision] : collisions; }; -function addAbility({ - oldAbilities, - ability, - atRowIndex, - atAbilityIndex, -}: { - oldAbilities: BuildAbilitiesTupleWithUnknown; - ability: (typeof abilities)[number]; - atRowIndex?: number; - atAbilityIndex?: number; -}): BuildAbilitiesTupleWithUnknown { - const abilitiesClone = JSON.parse( - JSON.stringify(oldAbilities), - ) as BuildAbilitiesTupleWithUnknown; - - if (atRowIndex !== undefined && atAbilityIndex !== undefined) { - if (canPlaceAbilityAtSlot(atRowIndex, atAbilityIndex, ability)) { - abilitiesClone[atRowIndex][atAbilityIndex] = ability.name; - } - } else { - // place in the first empty valid slot - for (const [rowIndex, row] of abilitiesClone.entries()) { - for (const [abilityIndex, oldAbility] of row.entries()) { - if (oldAbility !== "UNKNOWN") { - continue; - } - - if (!canPlaceAbilityAtSlot(rowIndex, abilityIndex, ability)) { - continue; - } - - abilitiesClone[rowIndex][abilityIndex] = ability.name; - - return abilitiesClone; - } - } - } - - return abilitiesClone; +function isSameSlot(a: AbilitySlots.Slot, b: AbilitySlots.Slot) { + return a.rowI === b.rowI && a.abilityI === b.abilityI; } diff --git a/app/components/Ability.module.css b/app/components/Ability.module.css index db787ca9e..8230f426a 100644 --- a/app/components/Ability.module.css +++ b/app/components/Ability.module.css @@ -13,17 +13,6 @@ user-select: none; } -.isDragTarget { - background: var(--color-bg-ability); - transform: scale(1.15); -} - -.dragStarted:not(.dropAllowed) { - filter: grayscale(1); - opacity: 0.3; - pointer-events: none; -} - .readonly, .readonly:active { cursor: default; diff --git a/app/components/Ability.tsx b/app/components/Ability.tsx index 7a37edfa4..9312464db 100644 --- a/app/components/Ability.tsx +++ b/app/components/Ability.tsx @@ -1,5 +1,4 @@ import clsx from "clsx"; -import React from "react"; import { useTranslation } from "react-i18next"; import type { AbilityWithUnknown } from "~/modules/in-game-lists/types"; import { abilityImageUrl } from "~/utils/urls"; @@ -17,34 +16,17 @@ const sizeMap = { export function Ability({ ability, size, - dragStarted = false, - dropAllowed = false, onClick, - onDrop, className, }: { ability: AbilityWithUnknown; size: keyof typeof sizeMap; - dragStarted?: boolean; - dropAllowed?: boolean; onClick?: () => void; - onDrop?: (event: React.DragEvent) => void; className?: string; }) { const { t } = useTranslation(["game-misc", "builds"]); const sizeNumber = sizeMap[size]; - const [isDragTarget, setIsDragTarget] = React.useState(false); - - const onDragOver = (event: React.DragEvent) => { - event.preventDefault(); - setIsDragTarget(true); - }; - - const onDragLeave = () => { - setIsDragTarget(false); - }; - const readonly = typeof onClick === "undefined" || ability === "UNKNOWN"; // Force "UNKNOWN" ability icons to be readonly const AbilityTag = readonly ? "div" : "button"; @@ -59,9 +41,6 @@ export function Ability({ className={clsx( styles.ability, { - [styles.isDragTarget]: isDragTarget, - [styles.dragStarted]: dragStarted, - [styles.dropAllowed]: dropAllowed, [styles.readonly]: readonly, }, className, @@ -71,12 +50,6 @@ export function Ability({ }} onClick={onClick} data-testid={`${ability}-ability`} - onDragOver={onDragOver} - onDragLeave={onDragLeave} - onDrop={(event) => { - setIsDragTarget(false); - onDrop?.(event); - }} type={readonly ? undefined : "button"} > { + test.each<{ + why: string; + ability: Ability; + rowI: number; + abilityI: number; + expected: boolean; + }>([ + { + why: "stackable in main", + ability: "ISM", + rowI: 1, + abilityI: 0, + expected: true, + }, + { + why: "stackable in sub", + ability: "ISM", + rowI: 2, + abilityI: 3, + expected: true, + }, + { + why: "head-only in head main", + ability: "OG", + rowI: 0, + abilityI: 0, + expected: true, + }, + { + why: "head-only in clothes main", + ability: "OG", + rowI: 1, + abilityI: 0, + expected: false, + }, + { + why: "head-only in head sub", + ability: "OG", + rowI: 0, + abilityI: 1, + expected: false, + }, + { + why: "clothes-only in clothes main", + ability: "AD", + rowI: 1, + abilityI: 0, + expected: true, + }, + { + why: "shoes-only in shoes main", + ability: "SJ", + rowI: 2, + abilityI: 0, + expected: true, + }, + { + why: "shoes-only in head main", + ability: "SJ", + rowI: 0, + abilityI: 0, + expected: false, + }, + ])("$why", ({ ability, rowI, abilityI, expected }) => { + expect(AbilitySlots.canPlaceAt(ability, { rowI, abilityI })).toBe(expected); + }); +}); + +describe("AbilitySlots.firstEmptyValidSlot", () => { + test("returns the head main for a stackable in an empty build", () => { + expect(AbilitySlots.firstEmptyValidSlot(EMPTY_BUILD, "ISM")).toEqual({ + rowI: 0, + abilityI: 0, + }); + }); + + test("skips mains the ability is not legal in", () => { + expect(AbilitySlots.firstEmptyValidSlot(EMPTY_BUILD, "SJ")).toEqual({ + rowI: 2, + abilityI: 0, + }); + }); + + test("returns null for a main-only ability whose slot is taken", () => { + const build = AbilitySlots.add(EMPTY_BUILD, "OG"); + + expect(AbilitySlots.firstEmptyValidSlot(build, "LDE")).toBeNull(); + }); + + test("returns null for a stackable when every slot is taken", () => { + const build = AbilitySlots.add( + AbilitySlots.add(AbilitySlots.add(FULL_SUBS_BUILD, "OG"), "AD"), + "SJ", + ); + + expect(AbilitySlots.firstEmptyValidSlot(build, "ISM")).toBeNull(); + }); +}); + +describe("AbilitySlots.add", () => { + test("fills the first empty legal slot", () => { + expect(AbilitySlots.add(FULL_SUBS_BUILD, "RES")[0]).toEqual([ + "RES", + "ISM", + "ISM", + "ISM", + ]); + }); + + test("returns the build unchanged when there is no room", () => { + const build = AbilitySlots.add(EMPTY_BUILD, "OG"); + + expect(AbilitySlots.add(build, "LDE")).toBe(build); + }); + + test("does not mutate the input", () => { + AbilitySlots.add(EMPTY_BUILD, "ISM"); + + expect(EMPTY_BUILD[0][0]).toBe("UNKNOWN"); + }); +}); + +describe("AbilitySlots.placeAt", () => { + test("replaces an occupied slot", () => { + expect( + AbilitySlots.placeAt(FULL_SUBS_BUILD, "RES", { rowI: 0, abilityI: 2 })[0], + ).toEqual(["UNKNOWN", "ISM", "RES", "ISM"]); + }); + + test("ignores an illegal slot", () => { + expect( + AbilitySlots.placeAt(FULL_SUBS_BUILD, "OG", { rowI: 0, abilityI: 2 }), + ).toBe(FULL_SUBS_BUILD); + }); +}); + +describe("AbilitySlots.remove", () => { + test("empties the slot", () => { + expect( + AbilitySlots.remove(FULL_SUBS_BUILD, { rowI: 1, abilityI: 1 })[1], + ).toEqual(["UNKNOWN", "UNKNOWN", "SSU", "SSU"]); + }); +}); + +describe("AbilitySlots.move", () => { + test("moves to an empty slot", () => { + const build = AbilitySlots.move( + FULL_SUBS_BUILD, + { rowI: 0, abilityI: 1 }, + { rowI: 1, abilityI: 0 }, + ); + + expect(build[0]).toEqual(["UNKNOWN", "UNKNOWN", "ISM", "ISM"]); + expect(build[1]).toEqual(["ISM", "SSU", "SSU", "SSU"]); + }); + + test("swaps with an occupied slot", () => { + const build = AbilitySlots.move( + FULL_SUBS_BUILD, + { rowI: 0, abilityI: 1 }, + { rowI: 2, abilityI: 3 }, + ); + + expect(build[0]).toEqual(["UNKNOWN", "QR", "ISM", "ISM"]); + expect(build[2]).toEqual(["UNKNOWN", "QR", "QR", "ISM"]); + }); + + test("refuses when the displaced ability is not legal in the origin", () => { + const build = AbilitySlots.add(FULL_SUBS_BUILD, "OG"); + const from = { rowI: 0, abilityI: 1 }; + const to = { rowI: 0, abilityI: 0 }; + + expect(AbilitySlots.canMove(build, from, to)).toBe(false); + expect(AbilitySlots.move(build, from, to)).toBe(build); + }); + + test("refuses when the moved ability is not legal in the target", () => { + const build = AbilitySlots.add(EMPTY_BUILD, "OG"); + + expect( + AbilitySlots.canMove( + build, + { rowI: 0, abilityI: 0 }, + { rowI: 1, abilityI: 0 }, + ), + ).toBe(false); + }); +}); diff --git a/app/components/AbilitySlots.ts b/app/components/AbilitySlots.ts new file mode 100644 index 000000000..dfb137c78 --- /dev/null +++ b/app/components/AbilitySlots.ts @@ -0,0 +1,126 @@ +import { abilities } from "~/modules/in-game-lists/abilities"; +import type { + Ability, + AbilityType, + BuildAbilitiesTupleWithUnknown, +} from "~/modules/in-game-lists/types"; + +export interface Slot { + rowI: number; + abilityI: number; +} + +const MAIN_ONLY_TYPE_BY_ROW: AbilityType[] = [ + "HEAD_MAIN_ONLY", + "CLOTHES_MAIN_ONLY", + "SHOES_MAIN_ONLY", +]; + +/** Whether the ability is legal in the slot, ignoring what currently occupies it. */ +export function canPlaceAt(ability: Ability, slot: Slot) { + const type = abilityType(ability); + + if (slot.abilityI !== 0) return type === "STACKABLE"; + + return type === "STACKABLE" || type === MAIN_ONLY_TYPE_BY_ROW[slot.rowI]; +} + +/** First empty slot the ability is legal in, or null if there is none. */ +export function firstEmptyValidSlot( + build: BuildAbilitiesTupleWithUnknown, + ability: Ability, +): Slot | null { + for (const [rowI, row] of build.entries()) { + for (const [abilityI, current] of row.entries()) { + if (current !== "UNKNOWN") continue; + + const slot = { rowI, abilityI }; + if (canPlaceAt(ability, slot)) return slot; + } + } + + return null; +} + +/** Places the ability in the first empty slot it is legal in, unchanged if there is none. */ +export function add( + build: BuildAbilitiesTupleWithUnknown, + ability: Ability, +): BuildAbilitiesTupleWithUnknown { + const slot = firstEmptyValidSlot(build, ability); + if (!slot) return build; + + return withSlot(build, slot, ability); +} + +/** Places the ability in the slot replacing what was there, unchanged if it is not legal there. */ +export function placeAt( + build: BuildAbilitiesTupleWithUnknown, + ability: Ability, + slot: Slot, +): BuildAbilitiesTupleWithUnknown { + if (!canPlaceAt(ability, slot)) return build; + + return withSlot(build, slot, ability); +} + +/** Empties the slot. */ +export function remove( + build: BuildAbilitiesTupleWithUnknown, + slot: Slot, +): BuildAbilitiesTupleWithUnknown { + return withSlot(build, slot, "UNKNOWN"); +} + +/** Whether the ability in `from` can be moved to `to`, swapping with whatever `to` holds. */ +export function canMove( + build: BuildAbilitiesTupleWithUnknown, + from: Slot, + to: Slot, +) { + const moving = abilityAt(build, from); + const displaced = abilityAt(build, to); + if (moving === "UNKNOWN") return false; + + return ( + canPlaceAt(moving, to) && + (displaced === "UNKNOWN" || canPlaceAt(displaced, from)) + ); +} + +/** Moves the ability in `from` to `to` swapping the two, unchanged if not allowed. */ +export function move( + build: BuildAbilitiesTupleWithUnknown, + from: Slot, + to: Slot, +): BuildAbilitiesTupleWithUnknown { + if (!canMove(build, from, to)) return build; + + return withSlot( + withSlot(build, to, abilityAt(build, from)), + from, + abilityAt(build, to), + ); +} + +function abilityType(ability: Ability) { + const found = abilities.find((a) => a.name === ability); + if (!found) throw new Error(`Unknown ability: ${ability}`); + + return found.type; +} + +function abilityAt(build: BuildAbilitiesTupleWithUnknown, slot: Slot) { + return build[slot.rowI][slot.abilityI]; +} + +function withSlot( + build: BuildAbilitiesTupleWithUnknown, + slot: Slot, + ability: BuildAbilitiesTupleWithUnknown[number][number], +): BuildAbilitiesTupleWithUnknown { + const result = structuredClone(build); + result[slot.rowI][slot.abilityI] = ability; + + return result; +} diff --git a/changelog/2026-09-29-ability-selector-improvements.md b/changelog/2026-09-29-ability-selector-improvements.md new file mode 100644 index 000000000..904d731b9 --- /dev/null +++ b/changelog/2026-09-29-ability-selector-improvements.md @@ -0,0 +1,11 @@ +--- +navItem: [analyzer, builds] +type: feature +--- +Ability selector improvements in the build analyzer and build form + +- Drag and drop now works more consistently across platforms +- Drag placed abilities to another slot to move or swap them, or drag them away to remove them +- Abilities that have no free slot left look disabled +- Hovering an ability shows a faint preview of it in the slot it would go into +- Abilities are grouped by where they can be used