impress-2020/src/app/components/SpeciesColorPicker.js
Matchu 75fccf1adf Load best available pose in SpeciesFacesPicker
Oh right, I left in a hack to just always pick HAPPY_MASC or HAPPY_FEM, back when it was just the basic colors. Now that we're all the colors, we need to be able to handle fallbacks for missing or unlabeled poses, too!!
2021-02-03 02:10:52 -08:00

457 lines
13 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/react";
import { Delay, logAndCapture, 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,
colorPlaceholderText = "",
speciesPlaceholderText = "",
stateMustAlwaysBeValid = false,
isDisabled = false,
speciesIsDisabled = false,
size = "md",
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,
valids,
} = useAllValidPetPoses();
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 textColor = useColorModeValue("inherit", "green.50");
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;
console.debug(`SpeciesColorPicker.onChangeColor`, {
// for IMPRESS-2020-1H
speciesId,
colorId,
newColorId,
});
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.
logAndCapture(
new Error(
`Assertion error in SpeciesColorPicker: Entered an invalid state, ` +
`with prop stateMustAlwaysBeValid: speciesId=${speciesId}, ` +
`colorId=${newColorId}.`
)
);
return;
}
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;
console.debug(`SpeciesColorPicker.onChangeSpecies`, {
// for IMPRESS-2020-1H
speciesId,
newSpeciesId,
colorId,
});
const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
if (!newSpecies) {
// Trying to isolate Sentry issue IMPRESS-2020-1H, where an empty species
// ends up coming out of `onChange`!
console.debug({ allSpecies, loadingMeta, errorMeta, meta });
logAndCapture(
new Error(
`Assertion error in SpeciesColorPicker: species not found. ` +
`speciesId=${speciesId}, newSpeciesId=${newSpeciesId}, ` +
`colorId=${colorId}.`
)
);
return;
}
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.)
//
// Also, if a color is provided that wouldn't normally be visible, we still
// show it. This can happen when someone models a new species/color combo for
// the first time - the boxes will still be red as if it were invalid, but
// this still smooths out the experience a lot.
let visibleColors = allColors;
if (stateMustAlwaysBeValid && valids && speciesId) {
visibleColors = visibleColors.filter(
(c) => getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId
);
}
return (
<Flex direction="row">
<SpeciesColorSelect
aria-label="Pet color"
value={colorId}
// We also wait for the valid pairs before enabling, so users can't
// trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading.
isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId
}
isDisabled={isDisabled}
onChange={onChangeColor}
size={size}
valids={valids}
speciesId={speciesId}
colorId={colorId}
>
{
// If the selected color isn't in the set we have here, show the
// placeholder. (Can happen during loading, or if an invalid color ID
// like null is intentionally provided while the real value loads.)
!visibleColors.some((c) => c.id === colorId) && (
<option>{colorPlaceholderText}</option>
)
}
{
// A long name for sizing! Should appear below the placeholder, out
// of view.
visibleColors.length === 0 && <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}
// We also wait for the valid pairs before enabling, so users can't
// trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading.
isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId
}
isDisabled={isDisabled || speciesIsDisabled}
// Don't fade out in the speciesIsDisabled case; it's more like a
// read-only state.
_disabled={
speciesIsDisabled
? { opacity: "1", cursor: "not-allowed" }
: undefined
}
onChange={onChangeSpecies}
size={size}
valids={valids}
speciesId={speciesId}
colorId={colorId}
>
{
// If the selected species isn't in the set we have here, show the
// placeholder. (Can happen during loading, or if an invalid species
// ID like null is intentionally provided while the real value
// loads.)
!allSpecies.some((s) => s.id === speciesId) && (
<option>{speciesPlaceholderText}</option>
)
}
{
// A long name for sizing! Should appear below the placeholder, out
// of view.
allSpecies.length === 0 && <option>Tuskaninny</option>
}
{allSpecies.map((species) => (
<option key={species.id} value={species.id}>
{species.name}
</option>
))}
</SpeciesColorSelect>
</Flex>
);
}
const SpeciesColorSelect = ({
size,
valids,
speciesId,
colorId,
isDisabled,
isLoading,
...props
}) => {
const backgroundColor = useColorModeValue("white", "gray.600");
const borderColor = useColorModeValue("green.600", "transparent");
const textColor = useColorModeValue("inherit", "green.50");
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!
opacity: "1 !important",
cursor: "wait !important",
}
: {};
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 &&
speciesId &&
colorId &&
!pairIsValid(valids, speciesId, colorId)
}
isDisabled={isDisabled || isLoading}
errorBorderColor="red.300"
{...props}
{...loadingProps}
/>
);
};
export function useAllValidPetPoses() {
const { loading, error, data: validsBuffer } = useFetch(
"/api/validPetPoses",
{ responseType: "arrayBuffer" }
);
const valids = React.useMemo(
() => validsBuffer && new DataView(validsBuffer),
[validsBuffer]
);
return { loading, error, valids };
}
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;
try {
return valids.getUint8(pairByteIndex);
} catch (e) {
logAndCapture(
new Error(
`Error loading valid poses for species=${speciesId}, color=${colorId}: ${e.message}`
)
);
return 0;
}
}
function pairIsValid(valids, speciesId, colorId) {
return getPairByte(valids, speciesId, colorId) !== 0;
}
export 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");
if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
if (pairByte & 0b10000000) validPoses.add("UNKNOWN");
return validPoses;
}
export 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);