impress-2020/src/app/WardrobePage/PosePicker.js

482 lines
15 KiB
JavaScript
Raw Normal View History

2020-05-02 15:41:02 -07:00
import React from "react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
2020-05-02 15:41:02 -07:00
import { css, cx } from "emotion";
import {
Box,
Button,
Flex,
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
Portal,
2020-05-02 22:04:20 -07:00
VisuallyHidden,
2020-05-02 15:41:02 -07:00
useTheme,
} from "@chakra-ui/core";
import {
getVisibleLayers,
petAppearanceFragment,
} from "../components/useOutfitAppearance";
import { OutfitLayers } from "../components/OutfitPreview";
2020-05-02 15:41:02 -07:00
2020-05-02 16:03:23 -07:00
// From https://twemoji.twitter.com/, thank you!
2020-07-20 21:41:26 -07:00
import twemojiSmile from "../../images/twemoji/smile.svg";
import twemojiCry from "../../images/twemoji/cry.svg";
import twemojiSick from "../../images/twemoji/sick.svg";
import twemojiMasc from "../../images/twemoji/masc.svg";
import twemojiFem from "../../images/twemoji/fem.svg";
2020-05-02 16:03:23 -07:00
/**
* PosePicker shows the pet poses available on the current species/color, and
* lets the user choose which want they want!
*
* NOTE: This component is memoized with React.memo. It's relatively expensive
* to re-render on every outfit change - the contents update even if the
* popover is closed! This makes wearing/unwearing items noticeably
* slower on lower-power devices.
*
* So, instead of using `outfitState` like most components, we specify
* exactly which props we need, so that `React.memo` can see the changes
* that matter, and skip updates that don't.
*/
function PosePicker({
speciesId,
colorId,
pose,
dispatchToOutfit,
onLockFocus,
onUnlockFocus,
}) {
2020-05-02 15:41:02 -07:00
const theme = useTheme();
2020-05-02 22:04:20 -07:00
const checkedInputRef = React.useRef();
const { loading, error, poseInfos } = usePoses(speciesId, colorId);
2020-05-02 22:04:20 -07:00
if (loading) {
return null;
}
// This is a low-stakes enough control, where enough pairs don't have data
// anyway, that I think I want to just not draw attention to failures.
if (error) {
return null;
}
// If there's only one pose anyway, don't bother showing a picker!
2020-05-23 12:47:06 -07:00
const numAvailablePoses = Object.values(poseInfos).filter(
(p) => p.isAvailable
).length;
if (numAvailablePoses <= 1) {
return null;
}
const onChange = (e) => {
2020-05-23 12:47:06 -07:00
dispatchToOutfit({ type: "setPose", pose: e.target.value });
};
2020-05-02 15:41:02 -07:00
return (
<Popover
2020-05-02 22:04:20 -07:00
placement="bottom-end"
2020-05-02 23:04:31 -07:00
returnFocusOnClose
2020-05-02 15:41:02 -07:00
onOpen={onLockFocus}
onClose={onUnlockFocus}
2020-05-02 22:04:20 -07:00
initialFocusRef={checkedInputRef}
2020-05-02 15:41:02 -07:00
>
{({ isOpen }) => (
<>
<PopoverTrigger>
<Button
variant="unstyled"
boxShadow="md"
d="flex"
alignItems="center"
justifyContent="center"
_focus={{ borderColor: "gray.50" }}
_hover={{ borderColor: "gray.50" }}
outline="initial"
className={cx(
css`
2020-05-02 16:03:23 -07:00
border: 1px solid transparent !important;
transition: border-color 0.2s !important;
2020-05-02 15:41:02 -07:00
&:focus,
&:hover,
&.is-open {
2020-05-02 16:03:23 -07:00
border-color: ${theme.colors.gray["50"]} !important;
}
&.is-open {
border-width: 2px !important;
2020-05-02 15:41:02 -07:00
}
`,
isOpen && "is-open"
)}
>
{getEmotion(pose) === "HAPPY" && (
2020-05-02 23:04:31 -07:00
<EmojiImage src={twemojiSmile} alt="Choose a pose" />
)}
{getEmotion(pose) === "SAD" && (
2020-05-02 23:04:31 -07:00
<EmojiImage src={twemojiCry} alt="Choose a pose" />
)}
{getEmotion(pose) === "SICK" && (
2020-05-02 23:04:31 -07:00
<EmojiImage src={twemojiSick} alt="Choose a pose" />
)}
2020-05-02 15:41:02 -07:00
</Button>
</PopoverTrigger>
<Portal>
<PopoverContent>
<Box p="4">
<table width="100%">
<thead>
<tr>
<th />
<Cell as="th">
<EmojiImage src={twemojiSmile} alt="Happy" />
</Cell>
<Cell as="th">
<EmojiImage src={twemojiCry} alt="Sad" />
</Cell>
<Cell as="th">
<EmojiImage src={twemojiSick} alt="Sick" />
</Cell>
</tr>
</thead>
<tbody>
<tr>
<Cell as="th">
<EmojiImage src={twemojiMasc} alt="Masculine" />
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.happyMasc}
onChange={onChange}
inputRef={
poseInfos.happyMasc.isSelected && checkedInputRef
}
/>
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.sadMasc}
onChange={onChange}
inputRef={
poseInfos.sadMasc.isSelected && checkedInputRef
}
/>
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.sickMasc}
onChange={onChange}
inputRef={
poseInfos.sickMasc.isSelected && checkedInputRef
}
/>
</Cell>
</tr>
<tr>
<Cell as="th">
<EmojiImage src={twemojiFem} alt="Feminine" />
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.happyFem}
onChange={onChange}
inputRef={
poseInfos.happyFem.isSelected && checkedInputRef
}
/>
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.sadFem}
onChange={onChange}
inputRef={
poseInfos.sadFem.isSelected && checkedInputRef
}
/>
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.sickFem}
onChange={onChange}
inputRef={
poseInfos.sickFem.isSelected && checkedInputRef
}
/>
</Cell>
</tr>
</tbody>
</table>
</Box>
<PopoverArrow />
</PopoverContent>
</Portal>
2020-05-02 15:41:02 -07:00
</>
)}
</Popover>
);
}
2020-05-02 16:03:23 -07:00
function Cell({ children, as }) {
const Tag = as;
2020-05-02 15:41:02 -07:00
return (
2020-05-02 16:03:23 -07:00
<Tag>
2020-05-02 15:41:02 -07:00
<Flex justify="center" p="1">
{children}
</Flex>
2020-05-02 16:03:23 -07:00
</Tag>
2020-05-02 15:41:02 -07:00
);
}
2020-05-02 22:04:20 -07:00
const EMOTION_STRINGS = {
HAPPY_MASC: "Happy",
HAPPY_FEM: "Happy",
SAD_MASC: "Sad",
SAD_FEM: "Sad",
SICK_MASC: "Sick",
SICK_FEM: "Sick",
2020-05-02 22:04:20 -07:00
};
const GENDER_PRESENTATION_STRINGS = {
HAPPY_MASC: "Masculine",
SAD_MASC: "Masculine",
SICK_MASC: "Masculine",
HAPPY_FEM: "Feminine",
SAD_FEM: "Feminine",
SICK_FEM: "Feminine",
2020-05-02 22:04:20 -07:00
};
2020-05-23 12:47:06 -07:00
function PoseOption({ poseInfo, onChange, inputRef }) {
2020-05-02 22:04:20 -07:00
const theme = useTheme();
const genderPresentationStr = GENDER_PRESENTATION_STRINGS[poseInfo.pose];
const emotionStr = EMOTION_STRINGS[poseInfo.pose];
2020-05-02 22:04:20 -07:00
2020-05-02 22:59:30 -07:00
let label = `${emotionStr} and ${genderPresentationStr}`;
2020-05-23 12:47:06 -07:00
if (!poseInfo.isAvailable) {
2020-05-02 22:59:30 -07:00
label += ` (not modeled yet)`;
}
2020-05-02 15:41:02 -07:00
return (
2020-05-02 21:04:54 -07:00
<Box
2020-05-02 22:04:20 -07:00
as="label"
cursor="pointer"
onClick={(e) => {
// HACK: We need the timeout to beat the popover's focus stealing!
const input = e.currentTarget.querySelector("input");
setTimeout(() => input.focus(), 0);
}}
2020-05-02 21:04:54 -07:00
>
2020-05-02 22:04:20 -07:00
<VisuallyHidden
as="input"
type="radio"
2020-05-02 22:59:30 -07:00
aria-label={label}
2020-05-02 22:04:20 -07:00
name="pose"
2020-05-23 12:47:06 -07:00
value={poseInfo.pose}
checked={poseInfo.isSelected}
disabled={!poseInfo.isAvailable}
onChange={onChange}
ref={inputRef || null}
2020-05-02 22:04:20 -07:00
/>
<Box
2020-05-02 22:59:30 -07:00
aria-hidden
borderRadius="full"
2020-05-02 22:04:20 -07:00
boxShadow="md"
overflow="hidden"
width="50px"
height="50px"
2020-05-02 22:40:34 -07:00
title={
2020-05-23 12:47:06 -07:00
poseInfo.isAvailable
2020-05-02 22:59:30 -07:00
? // A lil debug output, so that we can quickly identify glitched
// PetStates and manually mark them as glitched!
window.location.hostname.includes("localhost") &&
2020-05-23 12:47:06 -07:00
`#${poseInfo.petStateId}`
2020-05-02 22:59:30 -07:00
: "Not modeled yet"
2020-05-02 22:40:34 -07:00
}
2020-05-02 22:04:20 -07:00
position="relative"
className={css`
transform: scale(0.8);
opacity: 0.8;
transition: all 0.2s;
input:checked + & {
transform: scale(1);
opacity: 1;
}
`}
>
<Box
borderRadius="full"
2020-05-02 22:04:20 -07:00
position="absolute"
top="0"
bottom="0"
left="0"
right="0"
zIndex="2"
2020-05-02 22:59:30 -07:00
className={cx(
css`
border: 0px solid ${theme.colors.green["600"]};
transition: border-width 0.2s;
2020-05-02 22:04:20 -07:00
2020-05-02 22:59:30 -07:00
&.not-available {
border-color: ${theme.colors.gray["500"]};
border-width: 1px;
}
2020-05-02 22:04:20 -07:00
2020-05-02 22:59:30 -07:00
input:checked + * & {
border-width: 1px;
}
input:focus + * & {
border-width: 3px;
}
`,
2020-05-23 12:47:06 -07:00
!poseInfo.isAvailable && "not-available"
2020-05-02 22:59:30 -07:00
)}
2020-05-02 22:04:20 -07:00
/>
2020-05-23 12:47:06 -07:00
{poseInfo.isAvailable ? (
2020-05-02 22:59:30 -07:00
<Box
width="50px"
height="50px"
transform={
2020-05-23 12:47:06 -07:00
transformsByBodyId[poseInfo.bodyId] || transformsByBodyId.default
2020-05-02 22:59:30 -07:00
}
>
2020-05-23 12:47:06 -07:00
<OutfitLayers visibleLayers={getVisibleLayers(poseInfo, [])} />
2020-05-02 22:59:30 -07:00
</Box>
) : (
<Flex align="center" justify="center">
<Box
fontFamily="Delicious, sans-serif"
2020-05-02 22:59:30 -07:00
fontSize="3xl"
fontWeight="900"
color="gray.600"
>
?
</Box>
</Flex>
)}
2020-05-02 22:04:20 -07:00
</Box>
2020-05-02 15:41:02 -07:00
</Box>
);
}
2020-05-02 23:04:31 -07:00
function EmojiImage({ src, alt }) {
return <img src={src} alt={alt} width="16px" height="16px" />;
2020-05-02 16:03:23 -07:00
}
function usePoses(speciesId, colorId, selectedPose) {
const { loading, error, data } = useQuery(
gql`
query PosePicker($speciesId: ID!, $colorId: ID!) {
petAppearances(speciesId: $speciesId, colorId: $colorId) {
2020-05-02 21:04:54 -07:00
id
2020-05-02 22:40:34 -07:00
petStateId
bodyId
2020-05-23 12:47:06 -07:00
pose
...PetAppearanceForOutfitPreview
}
}
${petAppearanceFragment}
`,
{ variables: { speciesId, colorId } }
);
const petAppearances = data?.petAppearances || [];
2020-05-23 12:47:06 -07:00
const buildPoseInfo = (pose) => {
const appearance = petAppearances.find((pa) => pa.pose === pose);
2020-05-02 22:59:30 -07:00
return {
...appearance,
isAvailable: Boolean(appearance),
isSelected: selectedPose === pose,
2020-05-02 22:59:30 -07:00
};
};
2020-05-23 12:47:06 -07:00
const poseInfos = {
happyMasc: buildPoseInfo("HAPPY_MASC"),
sadMasc: buildPoseInfo("SAD_MASC"),
sickMasc: buildPoseInfo("SICK_MASC"),
happyFem: buildPoseInfo("HAPPY_FEM"),
sadFem: buildPoseInfo("SAD_FEM"),
sickFem: buildPoseInfo("SICK_FEM"),
};
2020-05-23 12:47:06 -07:00
return { loading, error, poseInfos };
}
function getEmotion(pose) {
if (["HAPPY_MASC", "HAPPY_FEM"].includes(pose)) {
return "HAPPY";
} else if (["SAD_MASC", "SAD_FEM"].includes(pose)) {
return "SAD";
} else if (["SICK_MASC", "SICK_FEM"].includes(pose)) {
return "SICK";
} else if (["UNCONVERTED", "UNKNOWN"].includes(pose)) {
return null;
} else {
throw new Error(`unrecognized pose ${JSON.stringify(pose)}`);
}
}
const transformsByBodyId = {
"93": "translate(-5px, 10px) scale(2.8)",
"106": "translate(-8px, 8px) scale(2.9)",
"47": "translate(-1px, 17px) scale(3)",
"84": "translate(-21px, 22px) scale(3.2)",
"146": "translate(2px, 15px) scale(3.3)",
"250": "translate(-14px, 28px) scale(3.4)",
"212": "translate(-4px, 8px) scale(2.9)",
"74": "translate(-26px, 30px) scale(3.0)",
"94": "translate(-4px, 8px) scale(3.1)",
"132": "translate(-14px, 18px) scale(3.0)",
"56": "translate(-7px, 24px) scale(2.9)",
"90": "translate(-16px, 20px) scale(3.5)",
"136": "translate(-11px, 18px) scale(3.0)",
"138": "translate(-14px, 26px) scale(3.5)",
"166": "translate(-13px, 24px) scale(3.1)",
"119": "translate(-6px, 29px) scale(3.1)",
"126": "translate(3px, 13px) scale(3.1)",
"67": "translate(2px, 27px) scale(3.4)",
"163": "translate(-7px, 16px) scale(3.1)",
"147": "translate(-2px, 15px) scale(3.0)",
"80": "translate(-2px, -17px) scale(3.0)",
"117": "translate(-14px, 16px) scale(3.6)",
"201": "translate(-16px, 16px) scale(3.2)",
"51": "translate(-2px, 6px) scale(3.2)",
"208": "translate(-3px, 6px) scale(3.7)",
"196": "translate(-7px, 19px) scale(5.2)",
"143": "translate(-16px, 20px) scale(3.5)",
"150": "translate(-3px, 24px) scale(3.2)",
"175": "translate(-9px, 15px) scale(3.4)",
"173": "translate(3px, 57px) scale(4.4)",
"199": "translate(-28px, 35px) scale(3.8)",
"52": "translate(-8px, 33px) scale(3.5)",
"109": "translate(-8px, -6px) scale(3.2)",
"134": "translate(-14px, 14px) scale(3.1)",
"95": "translate(-12px, 0px) scale(3.4)",
"96": "translate(6px, 23px) scale(3.3)",
"154": "translate(-20px, 25px) scale(3.6)",
"55": "translate(-16px, 28px) scale(4.0)",
"76": "translate(-8px, 11px) scale(3.0)",
"156": "translate(2px, 12px) scale(3.5)",
"78": "translate(-3px, 18px) scale(3.0)",
"191": "translate(-18px, 46px) scale(4.4)",
"187": "translate(-6px, 22px) scale(3.2)",
"46": "translate(-2px, 19px) scale(3.4)",
"178": "translate(-11px, 32px) scale(3.3)",
"100": "translate(-13px, 23px) scale(3.3)",
"130": "translate(-14px, 4px) scale(3.1)",
"188": "translate(-9px, 24px) scale(3.5)",
"257": "translate(-14px, 25px) scale(3.4)",
"206": "translate(-7px, 4px) scale(3.6)",
"101": "translate(-13px, 16px) scale(3.2)",
"68": "translate(-2px, 13px) scale(3.2)",
"182": "translate(-6px, 4px) scale(3.1)",
"180": "translate(-15px, 22px) scale(3.6)",
"306": "translate(1px, 14px) scale(3.1)",
2020-05-02 21:04:54 -07:00
default: "scale(2.5)",
};
export default React.memo(PosePicker);