forked from OpenNeo/impress-2020
Previously, when changing a pet's color, we would refresh the items panel and send a new network request for the item appearances, even though they're all the same. This is because item appearance data is queried by species/color, for ease of specification. But! Item appearances are //cached// by body ID. So, if this is a standard color, it's not hard to look in the cache for the standard color's body ID! Now, most color changes are faster and don't flicker the item panel anymore. We do still refresh the panel and send the requests for color changes that _do_ matter though, like standard <-> mutant!
349 lines
9.9 KiB
JavaScript
349 lines
9.9 KiB
JavaScript
import React from "react";
|
|
import gql from "graphql-tag";
|
|
import { useQuery } from "@apollo/client";
|
|
import { Box, Flex, Select, Text, useColorModeValue } from "@chakra-ui/core";
|
|
|
|
import { Delay, useFetch } from "../util";
|
|
|
|
/**
|
|
* SpeciesColorPicker lets the user pick the species/color of their pet.
|
|
*
|
|
* It preloads all species, colors, and valid species/color pairs; and then
|
|
* ensures that the outfit is always in a valid state.
|
|
*
|
|
* NOTE: This component is memoized with React.memo. It's not the cheapest to
|
|
* re-render on every outfit change. This contributes to
|
|
* wearing/unwearing items being noticeably slower on lower-power
|
|
* devices.
|
|
*/
|
|
function SpeciesColorPicker({
|
|
speciesId,
|
|
colorId,
|
|
idealPose,
|
|
showPlaceholders = false,
|
|
stateMustAlwaysBeValid = false,
|
|
isDisabled = false,
|
|
size = "md",
|
|
dark = false,
|
|
onChange,
|
|
}) {
|
|
const { loading: loadingMeta, error: errorMeta, data: meta } = useQuery(gql`
|
|
query SpeciesColorPicker {
|
|
allSpecies {
|
|
id
|
|
name
|
|
standardBodyId # Used for keeping items on during standard color changes
|
|
}
|
|
|
|
allColors {
|
|
id
|
|
name
|
|
isStandard # Used for keeping items on during standard color changes
|
|
}
|
|
}
|
|
`);
|
|
const {
|
|
loading: loadingValids,
|
|
error: errorValids,
|
|
data: validsBuffer,
|
|
} = useFetch("/api/validPetPoses", { responseType: "arrayBuffer" });
|
|
const valids = React.useMemo(
|
|
() => validsBuffer && new DataView(validsBuffer),
|
|
[validsBuffer]
|
|
);
|
|
|
|
const allColors = (meta && [...meta.allColors]) || [];
|
|
allColors.sort((a, b) => a.name.localeCompare(b.name));
|
|
const allSpecies = (meta && [...meta.allSpecies]) || [];
|
|
allSpecies.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
const backgroundColor = useColorModeValue("white", "gray.600");
|
|
const borderColor = useColorModeValue("green.600", "transparent");
|
|
const textColor = useColorModeValue("inherit", "green.50");
|
|
|
|
const SpeciesColorSelect = ({ isDisabled, isLoading, ...props }) => {
|
|
const loadingProps = isLoading
|
|
? {
|
|
// Visually the disabled state is the same as the normal state, but
|
|
// with a wait cursor. We don't expect this to take long, and the flash
|
|
// of content is rough! (The caret still flashes, but that's small and
|
|
// harder to style in Chakra.)
|
|
opacity: 1,
|
|
cursor: "wait",
|
|
}
|
|
: {};
|
|
|
|
return (
|
|
<Select
|
|
backgroundColor={backgroundColor}
|
|
color={textColor}
|
|
size={size}
|
|
border="1px"
|
|
borderColor={borderColor}
|
|
boxShadow="md"
|
|
width="auto"
|
|
transition="all 0.25s"
|
|
_hover={{
|
|
borderColor: "green.400",
|
|
}}
|
|
isInvalid={valids && !pairIsValid(valids, speciesId, colorId)}
|
|
isDisabled={isDisabled || isLoading}
|
|
errorBorderColor="red.300"
|
|
{...props}
|
|
{...loadingProps}
|
|
/>
|
|
);
|
|
};
|
|
|
|
if ((loadingMeta || loadingValids) && !showPlaceholders) {
|
|
return (
|
|
<Delay ms={5000}>
|
|
<Text color={textColor} textShadow="md">
|
|
Loading species/color data…
|
|
</Text>
|
|
</Delay>
|
|
);
|
|
}
|
|
|
|
if (errorMeta || errorValids) {
|
|
return (
|
|
<Text color={textColor} textShadow="md">
|
|
Error loading species/color data.
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
// When the color changes, check if the new pair is valid, and update the
|
|
// outfit if so!
|
|
const onChangeColor = (e) => {
|
|
const newColorId = e.target.value;
|
|
|
|
const species = allSpecies.find((s) => s.id === speciesId);
|
|
const newColor = allColors.find((c) => c.id === newColorId);
|
|
const validPoses = getValidPoses(valids, speciesId, newColorId);
|
|
const isValid = validPoses.size > 0;
|
|
if (stateMustAlwaysBeValid && !isValid) {
|
|
// NOTE: This shouldn't happen, because we should hide invalid colors.
|
|
console.error(
|
|
`Assertion error in SpeciesColorPicker: Entered an invalid state, ` +
|
|
`with prop stateMustAlwaysBeValid.`
|
|
);
|
|
}
|
|
const closestPose = getClosestPose(validPoses, idealPose);
|
|
onChange(species, newColor, isValid, closestPose);
|
|
};
|
|
|
|
// When the species changes, check if the new pair is valid, and update the
|
|
// outfit if so!
|
|
const onChangeSpecies = (e) => {
|
|
const newSpeciesId = e.target.value;
|
|
|
|
const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
|
|
let color = allColors.find((c) => c.id === colorId);
|
|
let validPoses = getValidPoses(valids, newSpeciesId, colorId);
|
|
let isValid = validPoses.size > 0;
|
|
|
|
if (stateMustAlwaysBeValid && !isValid) {
|
|
// If `stateMustAlwaysBeValid`, but the user switches to a species that
|
|
// doesn't support this color, that's okay and normal! We'll just switch
|
|
// to one of the four basic colors instead.
|
|
const basicColorId = ["8", "34", "61", "84"][
|
|
Math.floor(Math.random() * 4)
|
|
];
|
|
const basicColor = allColors.find((c) => c.id === basicColorId);
|
|
color = basicColor;
|
|
validPoses = getValidPoses(valids, newSpeciesId, color.id);
|
|
isValid = true;
|
|
}
|
|
|
|
const closestPose = getClosestPose(validPoses, idealPose);
|
|
onChange(newSpecies, color, isValid, closestPose);
|
|
};
|
|
|
|
// In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this
|
|
// species, so the user can't switch. (We handle species differently: if you
|
|
// switch to a new species and the color is invalid, we reset the color. We
|
|
// think this matches users' mental hierarchy of species -> color: showing
|
|
// supported colors for a species makes sense, but the other way around feels
|
|
// confusing and restrictive.)
|
|
let visibleColors = allColors;
|
|
if (stateMustAlwaysBeValid && valids) {
|
|
visibleColors = visibleColors.filter(
|
|
(c) => getValidPoses(valids, speciesId, c.id).size > 0
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Flex direction="row">
|
|
<SpeciesColorSelect
|
|
aria-label="Pet color"
|
|
value={colorId}
|
|
isLoading={allColors.length === 0}
|
|
isDisabled={isDisabled}
|
|
onChange={onChangeColor}
|
|
>
|
|
{allColors.length === 0 && (
|
|
<>
|
|
{/* The default case, and a long name for sizing! */}
|
|
<option>Blue</option>
|
|
<option>Dimensional</option>
|
|
</>
|
|
)}
|
|
{visibleColors.map((color) => (
|
|
<option key={color.id} value={color.id}>
|
|
{color.name}
|
|
</option>
|
|
))}
|
|
</SpeciesColorSelect>
|
|
<Box width={size === "sm" ? 2 : 4} />
|
|
<SpeciesColorSelect
|
|
aria-label="Pet species"
|
|
value={speciesId}
|
|
isLoading={allSpecies.length === 0}
|
|
isDisabled={isDisabled}
|
|
onChange={onChangeSpecies}
|
|
>
|
|
{allSpecies.length === 0 && (
|
|
<>
|
|
{/* The default case, and a long name for sizing! */}
|
|
<option>Acara</option>
|
|
<option>Tuskaninny</option>
|
|
</>
|
|
)}
|
|
{allSpecies.map((species) => (
|
|
<option key={species.id} value={species.id}>
|
|
{species.name}
|
|
</option>
|
|
))}
|
|
</SpeciesColorSelect>
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
function getPairByte(valids, speciesId, colorId) {
|
|
// Reading a bit table, owo!
|
|
const speciesIndex = speciesId - 1;
|
|
const colorIndex = colorId - 1;
|
|
const numColors = valids.getUint8(1);
|
|
const pairByteIndex = speciesIndex * numColors + colorIndex + 2;
|
|
return valids.getUint8(pairByteIndex);
|
|
}
|
|
|
|
function pairIsValid(valids, speciesId, colorId) {
|
|
return getPairByte(valids, speciesId, colorId) !== 0;
|
|
}
|
|
|
|
function getValidPoses(valids, speciesId, colorId) {
|
|
const pairByte = getPairByte(valids, speciesId, colorId);
|
|
|
|
const validPoses = new Set();
|
|
if (pairByte & 0b00000001) validPoses.add("HAPPY_MASC");
|
|
if (pairByte & 0b00000010) validPoses.add("SAD_MASC");
|
|
if (pairByte & 0b00000100) validPoses.add("SICK_MASC");
|
|
if (pairByte & 0b00001000) validPoses.add("HAPPY_FEM");
|
|
if (pairByte & 0b00010000) validPoses.add("SAD_FEM");
|
|
if (pairByte & 0b00100000) validPoses.add("SICK_FEM");
|
|
// TODO: Add unconverted support!
|
|
// if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
|
|
if (pairByte & 0b10000000) validPoses.add("UNKNOWN");
|
|
|
|
return validPoses;
|
|
}
|
|
|
|
function getClosestPose(validPoses, idealPose) {
|
|
return closestPosesInOrder[idealPose].find((p) => validPoses.has(p)) || null;
|
|
}
|
|
|
|
// For each pose, in what order do we prefer to match other poses?
|
|
//
|
|
// The principles of this ordering are:
|
|
// - Happy/sad matters more than gender presentation.
|
|
// - "Sick" is an unpopular emotion, and it's better to change gender
|
|
// presentation and stay happy/sad than to become sick.
|
|
// - Sad is a better fallback for sick than happy.
|
|
// - Unconverted vs converted is the biggest possible difference.
|
|
// - Unknown is the pose of last resort - even coming from another unknown.
|
|
const closestPosesInOrder = {
|
|
HAPPY_MASC: [
|
|
"HAPPY_MASC",
|
|
"HAPPY_FEM",
|
|
"SAD_MASC",
|
|
"SAD_FEM",
|
|
"SICK_MASC",
|
|
"SICK_FEM",
|
|
"UNCONVERTED",
|
|
"UNKNOWN",
|
|
],
|
|
HAPPY_FEM: [
|
|
"HAPPY_FEM",
|
|
"HAPPY_MASC",
|
|
"SAD_FEM",
|
|
"SAD_MASC",
|
|
"SICK_FEM",
|
|
"SICK_MASC",
|
|
"UNCONVERTED",
|
|
"UNKNOWN",
|
|
],
|
|
SAD_MASC: [
|
|
"SAD_MASC",
|
|
"SAD_FEM",
|
|
"HAPPY_MASC",
|
|
"HAPPY_FEM",
|
|
"SICK_MASC",
|
|
"SICK_FEM",
|
|
"UNCONVERTED",
|
|
"UNKNOWN",
|
|
],
|
|
SAD_FEM: [
|
|
"SAD_FEM",
|
|
"SAD_MASC",
|
|
"HAPPY_FEM",
|
|
"HAPPY_MASC",
|
|
"SICK_FEM",
|
|
"SICK_MASC",
|
|
"UNCONVERTED",
|
|
"UNKNOWN",
|
|
],
|
|
SICK_MASC: [
|
|
"SICK_MASC",
|
|
"SICK_FEM",
|
|
"SAD_MASC",
|
|
"SAD_FEM",
|
|
"HAPPY_MASC",
|
|
"HAPPY_FEM",
|
|
"UNCONVERTED",
|
|
"UNKNOWN",
|
|
],
|
|
SICK_FEM: [
|
|
"SICK_FEM",
|
|
"SICK_MASC",
|
|
"SAD_FEM",
|
|
"SAD_MASC",
|
|
"HAPPY_FEM",
|
|
"HAPPY_MASC",
|
|
"UNCONVERTED",
|
|
"UNKNOWN",
|
|
],
|
|
UNCONVERTED: [
|
|
"UNCONVERTED",
|
|
"HAPPY_FEM",
|
|
"HAPPY_MASC",
|
|
"SAD_FEM",
|
|
"SAD_MASC",
|
|
"SICK_FEM",
|
|
"SICK_MASC",
|
|
"UNKNOWN",
|
|
],
|
|
UNKNOWN: [
|
|
"HAPPY_FEM",
|
|
"HAPPY_MASC",
|
|
"SAD_FEM",
|
|
"SAD_MASC",
|
|
"SICK_FEM",
|
|
"SICK_MASC",
|
|
"UNCONVERTED",
|
|
"UNKNOWN",
|
|
],
|
|
};
|
|
|
|
export default React.memo(SpeciesColorPicker);
|