mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Ability selector redesign with dnd-kit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
208
app/components/AbilitySlots.test.ts
Normal file
208
app/components/AbilitySlots.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
126
app/components/AbilitySlots.ts
Normal file
126
app/components/AbilitySlots.ts
Normal 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;
|
||||
}
|
||||
11
changelog/2026-09-29-ability-selector-improvements.md
Normal file
11
changelog/2026-09-29-ability-selector-improvements.md
Normal 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
|
||||
Reference in New Issue
Block a user