mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-24 06:58:10 -05:00
210 lines
5.7 KiB
TypeScript
210 lines
5.7 KiB
TypeScript
import clsx from "clsx";
|
|
import * as React from "react";
|
|
import invariant from "tiny-invariant";
|
|
import { abilities } from "~/modules/in-game-lists";
|
|
import type { BuildAbilitiesTupleWithUnknown } from "~/modules/in-game-lists/types";
|
|
import { abilityImageUrl } from "~/utils/urls";
|
|
import { Ability } from "./Ability";
|
|
import { Image } from "./Image";
|
|
|
|
interface AbilitiesSelectorProps {
|
|
selectedAbilities: BuildAbilitiesTupleWithUnknown;
|
|
onChange: (newAbilities: BuildAbilitiesTupleWithUnknown) => void;
|
|
}
|
|
|
|
export function AbilitiesSelector({
|
|
selectedAbilities,
|
|
onChange,
|
|
}: AbilitiesSelectorProps) {
|
|
const [, startTransition] = React.useTransition();
|
|
|
|
const onSlotClick = ({
|
|
rowI,
|
|
abilityI,
|
|
}: {
|
|
rowI: number;
|
|
abilityI: number;
|
|
}) => {
|
|
const abilitiesClone = JSON.parse(
|
|
JSON.stringify(selectedAbilities),
|
|
) as BuildAbilitiesTupleWithUnknown;
|
|
|
|
const row = abilitiesClone[rowI];
|
|
invariant(row);
|
|
invariant(row.length === 4);
|
|
|
|
// no need to trigger a rerender
|
|
if (row[abilityI] === "UNKNOWN") return;
|
|
|
|
row[abilityI] = "UNKNOWN";
|
|
|
|
onChange(abilitiesClone);
|
|
};
|
|
const onButtonClick = (ability: (typeof abilities)[number]) => {
|
|
startTransition(() => {
|
|
onChange(addAbility({ oldAbilities: 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 onDrop =
|
|
(atRowIndex: number, atAbilityIndex: number) =>
|
|
(event: React.DragEvent) => {
|
|
event.preventDefault();
|
|
const ability = JSON.parse(
|
|
event.dataTransfer.getData("text/plain"),
|
|
) as (typeof abilities)[number];
|
|
|
|
onChange(
|
|
addAbility({
|
|
oldAbilities: selectedAbilities,
|
|
ability,
|
|
atRowIndex,
|
|
atAbilityIndex,
|
|
}),
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="ability-selector__container" data-testid="ability-selector">
|
|
<div className="ability-selector__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)}
|
|
/>
|
|
)),
|
|
)}
|
|
</div>
|
|
<div className="ability-selector__ability-buttons">
|
|
{abilities.map((ability) => (
|
|
<button
|
|
key={ability.name}
|
|
className={clsx("ability-selector__ability-button", {
|
|
"is-dragging": ability.name === draggingAbility?.name,
|
|
})}
|
|
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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const canPlaceAbilityAtSlot = (
|
|
rowIndex: number,
|
|
abilityIndex: number,
|
|
ability?: (typeof abilities)[number],
|
|
) => {
|
|
if (!ability) {
|
|
return false;
|
|
}
|
|
|
|
const legalGearTypeForMain =
|
|
rowIndex === 0
|
|
? "HEAD_MAIN_ONLY"
|
|
: rowIndex === 1
|
|
? "CLOTHES_MAIN_ONLY"
|
|
: "SHOES_MAIN_ONLY";
|
|
|
|
const isMainSlot = abilityIndex === 0;
|
|
|
|
if (
|
|
!["STACKABLE", legalGearTypeForMain].includes(ability.type) &&
|
|
isMainSlot
|
|
) {
|
|
// Can't put this type of gear in main slot
|
|
return false;
|
|
}
|
|
|
|
if (!isMainSlot && ability.type !== "STACKABLE") {
|
|
// Can't put main slot only gear to sub slots
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
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) {
|
|
// Attempt to place the ability at a specific slot since we
|
|
// were given an atRowIndex and atAbilityIndex
|
|
if (canPlaceAbilityAtSlot(atRowIndex, atAbilityIndex, ability)) {
|
|
// Assign this ability to the slot
|
|
abilitiesClone[atRowIndex]![atAbilityIndex] = ability.name;
|
|
}
|
|
} else {
|
|
// Loop through all slots and attempt to place this ability
|
|
// in the first empty one
|
|
for (const [rowIndex, row] of abilitiesClone.entries()) {
|
|
for (const [abilityIndex, oldAbility] of row.entries()) {
|
|
if (oldAbility !== "UNKNOWN") {
|
|
// Skip any filled slots in this loop until we arrive at an empty one.
|
|
continue;
|
|
}
|
|
|
|
if (!canPlaceAbilityAtSlot(rowIndex, abilityIndex, ability)) {
|
|
// This ability isn't valid for this slot
|
|
continue;
|
|
}
|
|
|
|
// Assign this ability to the slot
|
|
abilitiesClone[rowIndex]![abilityIndex] = ability.name;
|
|
|
|
return abilitiesClone;
|
|
}
|
|
}
|
|
}
|
|
|
|
// no-op if no available slots
|
|
return abilitiesClone;
|
|
}
|