2020-04-25 04:33:05 -07:00
|
|
|
import React from "react";
|
|
|
|
import gql from "graphql-tag";
|
2020-07-31 23:10:34 -07:00
|
|
|
import { useQuery } from "@apollo/client";
|
2020-12-25 09:08:33 -08:00
|
|
|
import { Box, Flex, Select, Text, useColorModeValue } from "@chakra-ui/react";
|
2020-04-25 04:33:05 -07:00
|
|
|
|
2020-07-20 21:41:26 -07:00
|
|
|
import { Delay, useFetch } from "../util";
|
2020-04-25 04:33:05 -07:00
|
|
|
|
2020-04-26 01:44:26 -07:00
|
|
|
/**
|
|
|
|
* 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.
|
2020-08-04 23:58:52 -07:00
|
|
|
*
|
|
|
|
* 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.
|
2020-04-26 01:44:26 -07:00
|
|
|
*/
|
2020-05-10 00:21:04 -07:00
|
|
|
function SpeciesColorPicker({
|
|
|
|
speciesId,
|
|
|
|
colorId,
|
2020-05-23 13:23:24 -07:00
|
|
|
idealPose,
|
2020-08-01 14:12:57 -07:00
|
|
|
showPlaceholders = false,
|
2020-09-21 02:43:58 -07:00
|
|
|
colorPlaceholderText = "",
|
|
|
|
speciesPlaceholderText = "",
|
2020-08-19 19:05:44 -07:00
|
|
|
stateMustAlwaysBeValid = false,
|
2020-08-01 14:12:57 -07:00
|
|
|
isDisabled = false,
|
|
|
|
size = "md",
|
2020-05-10 00:21:04 -07:00
|
|
|
onChange,
|
|
|
|
}) {
|
2020-05-03 01:52:39 -07:00
|
|
|
const { loading: loadingMeta, error: errorMeta, data: meta } = useQuery(gql`
|
2021-01-21 14:57:21 -08:00
|
|
|
query SpeciesColorPicker {
|
2020-04-25 04:33:05 -07:00
|
|
|
allSpecies {
|
|
|
|
id
|
|
|
|
name
|
2020-08-31 18:25:42 -07:00
|
|
|
standardBodyId # Used for keeping items on during standard color changes
|
2020-04-25 04:33:05 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
allColors {
|
|
|
|
id
|
|
|
|
name
|
2020-08-31 18:25:42 -07:00
|
|
|
isStandard # Used for keeping items on during standard color changes
|
2020-04-25 04:33:05 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
`);
|
2020-05-03 01:52:39 -07:00
|
|
|
const {
|
|
|
|
loading: loadingValids,
|
|
|
|
error: errorValids,
|
|
|
|
data: validsBuffer,
|
2020-05-17 23:44:33 -07:00
|
|
|
} = useFetch("/api/validPetPoses", { responseType: "arrayBuffer" });
|
2020-05-03 01:52:39 -07:00
|
|
|
const valids = React.useMemo(
|
|
|
|
() => validsBuffer && new DataView(validsBuffer),
|
|
|
|
[validsBuffer]
|
|
|
|
);
|
2020-04-25 04:33:05 -07:00
|
|
|
|
2020-05-03 01:52:39 -07:00
|
|
|
const allColors = (meta && [...meta.allColors]) || [];
|
2020-04-25 04:33:05 -07:00
|
|
|
allColors.sort((a, b) => a.name.localeCompare(b.name));
|
2020-05-03 01:52:39 -07:00
|
|
|
const allSpecies = (meta && [...meta.allSpecies]) || [];
|
2020-04-25 04:33:05 -07:00
|
|
|
allSpecies.sort((a, b) => a.name.localeCompare(b.name));
|
2020-04-26 01:44:26 -07:00
|
|
|
|
2020-08-12 00:37:31 -07:00
|
|
|
const textColor = useColorModeValue("inherit", "green.50");
|
|
|
|
|
2020-05-10 00:21:04 -07:00
|
|
|
if ((loadingMeta || loadingValids) && !showPlaceholders) {
|
2020-04-25 04:33:05 -07:00
|
|
|
return (
|
|
|
|
<Delay ms={5000}>
|
2020-05-10 00:21:04 -07:00
|
|
|
<Text color={textColor} textShadow="md">
|
2020-04-25 04:33:05 -07:00
|
|
|
Loading species/color data…
|
|
|
|
</Text>
|
|
|
|
</Delay>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-05-03 01:52:39 -07:00
|
|
|
if (errorMeta || errorValids) {
|
2020-04-25 04:33:05 -07:00
|
|
|
return (
|
2020-05-10 00:21:04 -07:00
|
|
|
<Text color={textColor} textShadow="md">
|
2020-04-25 04:33:05 -07:00
|
|
|
Error loading species/color data.
|
|
|
|
</Text>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-04-26 01:44:26 -07:00
|
|
|
// When the color changes, check if the new pair is valid, and update the
|
|
|
|
// outfit if so!
|
2020-04-25 04:33:05 -07:00
|
|
|
const onChangeColor = (e) => {
|
2020-05-10 00:21:04 -07:00
|
|
|
const newColorId = e.target.value;
|
|
|
|
|
|
|
|
const species = allSpecies.find((s) => s.id === speciesId);
|
|
|
|
const newColor = allColors.find((c) => c.id === newColorId);
|
2020-05-23 13:23:24 -07:00
|
|
|
const validPoses = getValidPoses(valids, speciesId, newColorId);
|
|
|
|
const isValid = validPoses.size > 0;
|
2020-08-19 19:05:44 -07:00
|
|
|
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.`
|
|
|
|
);
|
|
|
|
}
|
2020-05-23 13:23:24 -07:00
|
|
|
const closestPose = getClosestPose(validPoses, idealPose);
|
|
|
|
onChange(species, newColor, isValid, closestPose);
|
2020-04-25 04:33:05 -07:00
|
|
|
};
|
|
|
|
|
2020-04-26 01:44:26 -07:00
|
|
|
// When the species changes, check if the new pair is valid, and update the
|
|
|
|
// outfit if so!
|
2020-04-25 04:33:05 -07:00
|
|
|
const onChangeSpecies = (e) => {
|
2020-05-10 00:21:04 -07:00
|
|
|
const newSpeciesId = e.target.value;
|
|
|
|
|
|
|
|
const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
|
2020-08-19 19:05:44 -07:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2020-05-23 13:23:24 -07:00
|
|
|
const closestPose = getClosestPose(validPoses, idealPose);
|
|
|
|
onChange(newSpecies, color, isValid, closestPose);
|
2020-04-25 04:33:05 -07:00
|
|
|
};
|
|
|
|
|
2020-08-19 19:05:44 -07:00
|
|
|
// 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.)
|
2020-10-06 06:37:51 -07:00
|
|
|
//
|
|
|
|
// 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.
|
2020-08-19 19:05:44 -07:00
|
|
|
let visibleColors = allColors;
|
2020-09-21 02:43:58 -07:00
|
|
|
if (stateMustAlwaysBeValid && valids && speciesId) {
|
2020-08-19 19:05:44 -07:00
|
|
|
visibleColors = visibleColors.filter(
|
2020-10-06 06:37:51 -07:00
|
|
|
(c) => getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId
|
2020-08-19 19:05:44 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-04-25 04:33:05 -07:00
|
|
|
return (
|
|
|
|
<Flex direction="row">
|
2020-05-10 00:21:04 -07:00
|
|
|
<SpeciesColorSelect
|
2020-04-25 04:33:05 -07:00
|
|
|
aria-label="Pet color"
|
2020-05-10 00:21:04 -07:00
|
|
|
value={colorId}
|
2020-08-01 14:12:57 -07:00
|
|
|
isLoading={allColors.length === 0}
|
|
|
|
isDisabled={isDisabled}
|
2020-04-25 04:33:05 -07:00
|
|
|
onChange={onChangeColor}
|
2020-12-04 13:01:39 -08:00
|
|
|
size={size}
|
|
|
|
valids={valids}
|
|
|
|
speciesId={speciesId}
|
|
|
|
colorId={colorId}
|
2020-04-25 04:33:05 -07:00
|
|
|
>
|
2020-09-21 02:43:58 -07:00
|
|
|
{
|
|
|
|
// 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>
|
|
|
|
}
|
2020-08-19 19:05:44 -07:00
|
|
|
{visibleColors.map((color) => (
|
2020-04-25 04:33:05 -07:00
|
|
|
<option key={color.id} value={color.id}>
|
|
|
|
{color.name}
|
|
|
|
</option>
|
|
|
|
))}
|
2020-05-10 00:21:04 -07:00
|
|
|
</SpeciesColorSelect>
|
2020-08-01 14:12:57 -07:00
|
|
|
<Box width={size === "sm" ? 2 : 4} />
|
2020-05-10 00:21:04 -07:00
|
|
|
<SpeciesColorSelect
|
2020-04-25 04:33:05 -07:00
|
|
|
aria-label="Pet species"
|
2020-05-10 00:21:04 -07:00
|
|
|
value={speciesId}
|
2020-08-01 14:12:57 -07:00
|
|
|
isLoading={allSpecies.length === 0}
|
|
|
|
isDisabled={isDisabled}
|
2020-04-25 04:33:05 -07:00
|
|
|
onChange={onChangeSpecies}
|
2020-12-04 13:01:39 -08:00
|
|
|
size={size}
|
|
|
|
valids={valids}
|
|
|
|
speciesId={speciesId}
|
|
|
|
colorId={colorId}
|
2020-04-25 04:33:05 -07:00
|
|
|
>
|
2020-09-21 02:43:58 -07:00
|
|
|
{
|
|
|
|
// 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>
|
|
|
|
}
|
2020-04-25 04:33:05 -07:00
|
|
|
{allSpecies.map((species) => (
|
|
|
|
<option key={species.id} value={species.id}>
|
|
|
|
{species.name}
|
|
|
|
</option>
|
|
|
|
))}
|
2020-05-10 00:21:04 -07:00
|
|
|
</SpeciesColorSelect>
|
2020-04-25 04:33:05 -07:00
|
|
|
</Flex>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-12-04 13:01:39 -08:00
|
|
|
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}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2020-05-23 13:23:24 -07:00
|
|
|
function getPairByte(valids, speciesId, colorId) {
|
2020-05-03 01:52:39 -07:00
|
|
|
// Reading a bit table, owo!
|
|
|
|
const speciesIndex = speciesId - 1;
|
|
|
|
const colorIndex = colorId - 1;
|
2020-05-03 13:02:28 -07:00
|
|
|
const numColors = valids.getUint8(1);
|
|
|
|
const pairByteIndex = speciesIndex * numColors + colorIndex + 2;
|
2020-05-23 13:23:24 -07:00
|
|
|
return valids.getUint8(pairByteIndex);
|
|
|
|
}
|
|
|
|
|
|
|
|
function pairIsValid(valids, speciesId, colorId) {
|
|
|
|
return getPairByte(valids, speciesId, colorId) !== 0;
|
2020-05-03 01:52:39 -07:00
|
|
|
}
|
|
|
|
|
2020-05-23 13:23:24 -07:00
|
|
|
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");
|
2020-08-31 20:26:15 -07:00
|
|
|
if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
|
2020-05-23 13:23:24 -07:00
|
|
|
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",
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
2020-08-04 23:58:52 -07:00
|
|
|
export default React.memo(SpeciesColorPicker);
|