Ability selector redesign with dnd-kit

This commit is contained in:
Kalle
2026-09-29 21:48:59 +03:00
parent 6c32519ba4
commit 954491c07e
7 changed files with 835 additions and 195 deletions

View File

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

View File

@@ -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<DragData | null>(null);
const [dropRemoves, setDropRemoves] = React.useState(false);
const [previewAbility, setPreviewAbility] =
React.useState<AbilityName | null>(null);
const previewTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(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) => (
<fieldset
key={group.type}
aria-label={group.label}
className={styles.paletteGroup}
>
<span aria-hidden className={styles.rowMarker}>
{marker}
</span>
{abilities
.filter((ability) => ability.type === group.type)
.map((ability) => (
<PaletteButton
key={ability.name}
ability={ability.name}
isUnplaceable={
!AbilitySlots.firstEmptyValidSlot(selectedAbilities, ability.name)
}
isDragging={!dragging?.from && dragging?.ability === ability.name}
onClick={() => handlePaletteClick(ability.name)}
onPreviewChange={(isPreviewing) =>
schedulePreview(isPreviewing ? ability.name : null)
}
/>
))}
</fieldset>
);
return (
<div className={styles.container} data-testid="ability-selector">
<div className={styles.slots}>
{selectedAbilities.map((row, rowI) =>
row.map((ability, abilityI) => (
<Ability
key={abilityI}
ability={ability}
size={abilityI === 0 ? "MAIN" : "SUB"}
onClick={() => onSlotClick({ rowI, abilityI })}
dragStarted={!!draggingAbility}
dropAllowed={canPlaceAbilityAtSlot(
rowI,
abilityI,
draggingAbility,
)}
onDrop={onDrop(rowI, abilityI)}
/>
)),
)}
<DndContext
sensors={sensors}
collisionDetection={slotsFirstCollisionDetection}
autoScroll={false}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={stopDragging}
>
<div className={styles.container} data-testid="ability-selector">
<SlotGrid>
{selectedAbilities.map((row, rowI) =>
row.map((ability, abilityI) => {
const slot = { rowI, abilityI };
return (
<AbilitySlot
key={`${rowI}-${abilityI}`}
slot={slot}
ability={ability}
isDragging={Boolean(dragging)}
isDragSource={
dragging?.from ? isSameSlot(dragging.from, slot) : false
}
isAllowed={isAllowedAt(slot)}
ghostAbility={
previewAbility &&
previewSlot &&
isSameSlot(previewSlot, slot)
? previewAbility
: null
}
onClick={() => handleSlotClick(slot)}
/>
);
}),
)}
</SlotGrid>
<div className={styles.palette}>
{renderPaletteGroup(
STACKABLE_GROUP,
<Layers size={22} strokeWidth={2.5} />,
)}
{MAIN_ONLY_GROUPS.map((group, i) => renderPaletteGroup(group, i + 1))}
</div>
</div>
<div className={styles.abilityButtons}>
{abilities.map((ability) => (
<button
key={ability.name}
className={clsx(styles.abilityButton, {
[styles.isDragging]: ability.name === draggingAbility?.name,
<DragOverlay dropAnimation={null}>
{dragging ? (
<div
className={clsx(styles.dragOverlay, {
[styles.dropRemoves]: dropRemoves,
})}
type="button"
onClick={() => onButtonClick(ability)}
data-testid={`${ability.name}-ability-button`}
draggable="true"
onDragStart={onDragStart(ability)}
onDragEnd={onDragEnd}
>
<Image
alt=""
path={abilityImageUrl(ability.name)}
width={32}
height={32}
/>
</button>
))}
</div>
<Ability ability={dragging.ability} size="SUB" />
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
function SlotGrid({ children }: { children: React.ReactNode }) {
const { setNodeRef } = useDroppable({ id: SLOTS_DROPPABLE_ID });
return (
<div ref={setNodeRef} className={styles.slots}>
{children}
</div>
);
}
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 (
<div
ref={(node) => {
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}
>
<Ability
ability={ability}
size={slot.abilityI === 0 ? "MAIN" : "SUB"}
onClick={onClick}
/>
{ghostAbility ? (
<Image
alt=""
path={abilityImageUrl(ghostAbility)}
containerClassName={styles.ghost}
/>
) : null}
</div>
);
}
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 (
<button
ref={setNodeRef}
className={clsx(styles.abilityButton, {
[styles.isDragging]: isDragging,
[styles.unplaceable]: isUnplaceable,
})}
type="button"
aria-label={name}
aria-disabled={isUnplaceable || undefined}
title={name}
onClick={isUnplaceable ? undefined : onClick}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") onPreviewChange(true);
}}
onPointerLeave={() => onPreviewChange(false)}
onFocus={(event) => {
if (event.currentTarget.matches(":focus-visible")) {
onPreviewChange(true);
}
}}
onBlur={() => onPreviewChange(false)}
data-testid={`${ability}-ability-button`}
{...listeners}
>
<Image alt="" path={abilityImageUrl(ability)} width={32} height={32} />
</button>
);
}
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;
}

View File

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

View File

@@ -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"}
>
<Image

View File

@@ -0,0 +1,208 @@
import { describe, expect, test } from "vitest";
import type {
Ability,
BuildAbilitiesTupleWithUnknown,
} from "~/modules/in-game-lists/types";
import * as AbilitySlots from "./AbilitySlots";
const EMPTY_BUILD: BuildAbilitiesTupleWithUnknown = [
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
];
const FULL_SUBS_BUILD: BuildAbilitiesTupleWithUnknown = [
["UNKNOWN", "ISM", "ISM", "ISM"],
["UNKNOWN", "SSU", "SSU", "SSU"],
["UNKNOWN", "QR", "QR", "QR"],
];
describe("AbilitySlots.canPlaceAt", () => {
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);
});
});

View File

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

View File

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