Compare commits

..

No commits in common. "c2de6f7167b3b9172f1f73b7a92afaa40cee6808" and "4025dcd96844e74009320598484c806b3327915d" have entirely different histories.

12 changed files with 46 additions and 173 deletions

View file

@ -11,9 +11,8 @@ class AltStylesController < ApplicationController
respond_to do |format| respond_to do |format|
format.html { render } format.html { render }
format.json { format.json {
render json: @alt_styles.includes(swf_assets: [:zone]).as_json( render json: @alt_styles.as_json(
include: {swf_assets: {include: [:zone], methods: [:image_url]}}, methods: [:adjective_name, :thumbnail_url],
methods: [:series_name, :adjective_name, :thumbnail_url],
) )
} }
end end

View file

@ -215,6 +215,12 @@ function OutfitControls({
* We try to center the species/color picker, but the left spacer will * We try to center the species/color picker, but the left spacer will
* shrink more than the pose picker container if we run out of space! * shrink more than the pose picker container if we run out of space!
*/} */}
<Flex
flex="1 1 0"
paddingRight="3"
align="center"
justify="flex-end"
/>
<Box flex="0 0 auto"> <Box flex="0 0 auto">
<DarkMode> <DarkMode>
<SpeciesColorPicker <SpeciesColorPicker
@ -228,12 +234,11 @@ function OutfitControls({
/> />
</DarkMode> </DarkMode>
</Box> </Box>
<Flex flex="0 0 auto" align="center" pl="2"> <Flex flex="1 1 0" align="center" pl="2">
<PosePicker <PosePicker
speciesId={outfitState.speciesId} speciesId={outfitState.speciesId}
colorId={outfitState.colorId} colorId={outfitState.colorId}
pose={outfitState.pose} pose={outfitState.pose}
altStyleId={outfitState.altStyleId}
appearanceId={outfitState.appearanceId} appearanceId={outfitState.appearanceId}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
onLockFocus={onLockFocus} onLockFocus={onLockFocus}

View file

@ -23,7 +23,6 @@ import {
useToast, useToast,
useToken, useToken,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { ChevronDownIcon } from "@chakra-ui/icons";
import { loadable } from "../util"; import { loadable } from "../util";
import { petAppearanceFragment } from "../components/useOutfitAppearance"; import { petAppearanceFragment } from "../components/useOutfitAppearance";
@ -66,7 +65,6 @@ function PosePicker({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
appearanceId, appearanceId,
dispatchToOutfit, dispatchToOutfit,
onLockFocus, onLockFocus,
@ -88,24 +86,8 @@ function PosePicker({
const poseInfos = posesQuery.poseInfos; const poseInfos = posesQuery.poseInfos;
const altStyles = altStylesQuery.data ?? []; const altStyles = altStylesQuery.data ?? [];
const [isOpen, setIsOpen] = React.useState(false);
const [tabIndex, setTabIndex] = React.useState(0);
const altStyle = altStyles.find((s) => s.id === altStyleId);
const placement = useBreakpointValue({ base: "bottom-end", md: "top-end" }); const placement = useBreakpointValue({ base: "bottom-end", md: "top-end" });
React.useEffect(() => {
// While the popover is open, don't change which tab is open.
if (isOpen) {
return;
}
// Otherwise, set the tab to Styles if we're wearing an Alt Style, or
// Expressions if we're not.
setTabIndex(altStyle != null ? 1 : 0);
}, [altStyle, isOpen]);
// Resize the Popover when we toggle support mode, because it probably will // Resize the Popover when we toggle support mode, because it probably will
// affect the content size. // affect the content size.
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
@ -162,6 +144,10 @@ function PosePicker({
dispatchToOutfit, dispatchToOutfit,
]); ]);
if (loading) {
return null;
}
// This is a low-stakes enough control, where enough pairs don't have data // 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. // anyway, that I think I want to just not draw attention to failures.
if (error) { if (error) {
@ -172,40 +158,24 @@ function PosePicker({
(p) => p.isAvailable && STANDARD_POSES.includes(p.pose), (p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
).length; ).length;
const onChangePose = (e) => { const onChange = (e) => {
dispatchToOutfit({ type: "setPose", pose: e.target.value }); dispatchToOutfit({ type: "setPose", pose: e.target.value });
}; };
const onChangeStyle = (altStyleId) => {
dispatchToOutfit({ type: "setStyle", altStyleId });
};
return ( return (
<Popover <Popover
placement={placement} placement={placement}
returnFocusOnClose returnFocusOnClose
onOpen={() => { onOpen={onLockFocus}
setIsOpen(true); onClose={onUnlockFocus}
onLockFocus();
}}
onClose={() => {
setIsOpen(false);
onUnlockFocus();
}}
initialFocusRef={initialFocusRef} initialFocusRef={initialFocusRef}
isLazy isLazy
lazyBehavior="keepMounted" lazyBehavior="keepMounted"
> >
{() => ( {({ isOpen }) => (
<> <>
<PopoverTrigger> <PopoverTrigger>
<PosePickerButton <PosePickerButton pose={pose} isOpen={isOpen} {...props} />
pose={pose}
altStyle={altStyle}
isOpen={isOpen}
loading={loading}
{...props}
/>
</PopoverTrigger> </PopoverTrigger>
<Portal> <Portal>
<PopoverContent> <PopoverContent>
@ -216,8 +186,6 @@ function PosePicker({
flexDirection={{ base: "column", md: "column-reverse" }} flexDirection={{ base: "column", md: "column-reverse" }}
paddingY="2" paddingY="2"
gap="2" gap="2"
index={tabIndex}
onChange={setTabIndex}
// HACK: To only apply `initialFocusRef` to the selected input // HACK: To only apply `initialFocusRef` to the selected input
// in the *active* tab, we just use `isLazy` to only *render* // in the *active* tab, we just use `isLazy` to only *render*
// the active tab. We could also watch the tab state and set // the active tab. We could also watch the tab state and set
@ -245,7 +213,7 @@ function PosePicker({
<> <>
<PosePickerTable <PosePickerTable
poseInfos={poseInfos} poseInfos={poseInfos}
onChange={onChangePose} onChange={onChange}
initialFocusRef={initialFocusRef} initialFocusRef={initialFocusRef}
/> />
{numStandardPoses == 0 && ( {numStandardPoses == 0 && (
@ -264,9 +232,7 @@ function PosePicker({
</TabPanel> </TabPanel>
<TabPanel paddingX="4" paddingY="0"> <TabPanel paddingX="4" paddingY="0">
<StyleSelect <StyleSelect
selectedStyleId={altStyleId}
altStyles={altStyles} altStyles={altStyles}
onChange={onChangeStyle}
initialFocusRef={initialFocusRef} initialFocusRef={initialFocusRef}
/> />
<StyleExplanation /> <StyleExplanation />
@ -282,12 +248,9 @@ function PosePicker({
); );
} }
function PosePickerButton({ pose, altStyle, isOpen, loading, ...props }, ref) { function PosePickerButton({ pose, isOpen, ...props }, ref) {
const theme = useTheme(); const theme = useTheme();
const icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
const label = altStyle != null ? altStyle.seriesName : getLabel(pose);
return ( return (
<ClassNames> <ClassNames>
{({ css, cx }) => ( {({ css, cx }) => (
@ -302,28 +265,16 @@ function PosePickerButton({ pose, altStyle, isOpen, loading, ...props }, ref) {
outline="initial" outline="initial"
fontSize="sm" fontSize="sm"
fontWeight="normal" fontWeight="normal"
minWidth="12ch"
disabled={loading}
className={cx( className={cx(
css` css`
border: 1px solid transparent !important; border: 1px solid transparent !important;
color: ${theme.colors.gray["300"]}; transition: border-color 0.2s !important;
cursor: ${loading ? "wait" : "pointer"} !important; padding-inline: 0.75em;
transition:
color 0.2s,
border-color 0.2s !important;
padding-left: 0.75em;
padding-right: 0.5em;
&:focus,
&:hover, &:hover,
&.is-open { &.is-open {
border-color: ${theme.colors.gray["50"]} !important; border-color: ${theme.colors.gray["50"]} !important;
color: ${theme.colors.gray["50"]};
}
&:focus {
border-color: ${theme.colors.gray["50"]} !important;
box-shadow: ${theme.shadows.outline};
} }
&.is-open { &.is-open {
@ -335,11 +286,11 @@ function PosePickerButton({ pose, altStyle, isOpen, loading, ...props }, ref) {
{...props} {...props}
ref={ref} ref={ref}
> >
<EmojiImage src={icon} alt="Style" /> <EmojiImage src={getIcon(pose)} alt="" />
<Box width=".5em" /> <SupportOnly>
{label} <Box width=".5em" />
<Box width=".5em" /> {getLabel(pose)}
<ChevronDownIcon /> </SupportOnly>
</Button> </Button>
)} )}
</ClassNames> </ClassNames>
@ -621,12 +572,9 @@ function PosePickerEmptyExplanation() {
); );
} }
function StyleSelect({ function StyleSelect({ altStyles, initialFocusRef }) {
selectedStyleId, const [selectedStyleId, setSelectedStyleId] = React.useState(null);
altStyles,
onChange,
initialFocusRef,
}) {
const defaultStyle = { id: null, adjectiveName: "Default" }; const defaultStyle = { id: null, adjectiveName: "Default" };
const styles = [defaultStyle, ...altStyles]; const styles = [defaultStyle, ...altStyles];
@ -645,7 +593,7 @@ function StyleSelect({
key={altStyle.id} key={altStyle.id}
altStyle={altStyle} altStyle={altStyle}
checked={selectedStyleId === altStyle.id} checked={selectedStyleId === altStyle.id}
onChange={onChange} onChange={setSelectedStyleId}
inputRef={selectedStyleId === altStyle.id ? initialFocusRef : null} inputRef={selectedStyleId === altStyle.id ? initialFocusRef : null}
/> />
))} ))}

View file

@ -24,7 +24,6 @@ function WardrobePreviewAndControls({
speciesId: outfitState.speciesId, speciesId: outfitState.speciesId,
colorId: outfitState.colorId, colorId: outfitState.colorId,
pose: outfitState.pose, pose: outfitState.pose,
altStyleId: outfitState.altStyleId,
appearanceId: outfitState.appearanceId, appearanceId: outfitState.appearanceId,
wornItemIds: outfitState.wornItemIds, wornItemIds: outfitState.wornItemIds,
onChangeHasAnimations: setHasAnimations, onChangeHasAnimations: setHasAnimations,

View file

@ -413,12 +413,11 @@ function ItemSupportPetCompatibilityRuleFields({
*/ */
function ItemSupportAppearanceLayers({ item }) { function ItemSupportAppearanceLayers({ item }) {
const outfitState = React.useContext(OutfitStateContext); const outfitState = React.useContext(OutfitStateContext);
const { speciesId, colorId, pose, altStyleId, appearanceId } = outfitState; const { speciesId, colorId, pose, appearanceId } = outfitState;
const { error, visibleLayers } = useOutfitAppearance({ const { error, visibleLayers } = useOutfitAppearance({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
appearanceId, appearanceId,
wornItemIds: [item.id], wornItemIds: [item.id],
}); });

View file

@ -113,8 +113,7 @@ function useOutfitState() {
// IDs. It's more convenient to manage them as a Set in state, but most // IDs. It's more convenient to manage them as a Set in state, but most
// callers will find it more convenient to access them as arrays! e.g. for // callers will find it more convenient to access them as arrays! e.g. for
// `.map()`. // `.map()`.
const { id, name, speciesId, colorId, pose, altStyleId, appearanceId } = const { id, name, speciesId, colorId, pose, appearanceId } = outfitState;
outfitState;
const wornItemIds = Array.from(outfitState.wornItemIds); const wornItemIds = Array.from(outfitState.wornItemIds);
const closetedItemIds = Array.from(outfitState.closetedItemIds); const closetedItemIds = Array.from(outfitState.closetedItemIds);
const allItemIds = [...wornItemIds, ...closetedItemIds]; const allItemIds = [...wornItemIds, ...closetedItemIds];
@ -237,7 +236,6 @@ function useOutfitState() {
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
appearanceId, appearanceId,
url, url,
@ -353,10 +351,6 @@ const outfitStateReducer = (apolloClient) => (baseState, action) => {
// particular about which version of the pose to show if more than one. // particular about which version of the pose to show if more than one.
state.appearanceId = action.appearanceId || null; state.appearanceId = action.appearanceId || null;
}); });
case "setStyle":
return produce(baseState, (state) => {
state.altStyleId = action.altStyleId;
});
case "resetToSavedOutfitData": case "resetToSavedOutfitData":
return getOutfitStateFromOutfitData(action.savedOutfitData); return getOutfitStateFromOutfitData(action.savedOutfitData);
default: default:
@ -423,7 +417,6 @@ function readOutfitStateFromSearchParams(pathname, searchParams) {
speciesId: searchParams.get("species") || "1", speciesId: searchParams.get("species") || "1",
colorId: searchParams.get("color") || "8", colorId: searchParams.get("color") || "8",
pose: searchParams.get("pose") || "HAPPY_FEM", pose: searchParams.get("pose") || "HAPPY_FEM",
altStyleId: searchParams.get("style") || null,
appearanceId: searchParams.get("state") || null, appearanceId: searchParams.get("state") || null,
wornItemIds: new Set(searchParams.getAll("objects[]")), wornItemIds: new Set(searchParams.getAll("objects[]")),
closetedItemIds: new Set(searchParams.getAll("closet[]")), closetedItemIds: new Set(searchParams.getAll("closet[]")),
@ -647,7 +640,6 @@ function buildOutfitQueryString(outfitState) {
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
appearanceId, appearanceId,
wornItemIds, wornItemIds,
closetedItemIds, closetedItemIds,
@ -665,9 +657,6 @@ function buildOutfitQueryString(outfitState) {
for (const itemId of closetedItemIds) { for (const itemId of closetedItemIds) {
params.append("closet[]", itemId); params.append("closet[]", itemId);
} }
if (altStyleId != null) {
params.append("style", altStyleId);
}
if (appearanceId != null) { if (appearanceId != null) {
// `state` is an old name for compatibility with old-style DTI URLs. It // `state` is an old name for compatibility with old-style DTI URLs. It
// refers to "PetState", the database table name for pet appearances. // refers to "PetState", the database table name for pet appearances.

View file

@ -52,7 +52,6 @@ export function useOutfitPreview({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
wornItemIds, wornItemIds,
appearanceId = null, appearanceId = null,
isLoading = false, isLoading = false,
@ -69,7 +68,6 @@ export function useOutfitPreview({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
appearanceId, appearanceId,
wornItemIds, wornItemIds,
}); });

View file

@ -1,20 +1,17 @@
import React from "react"; import React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import getVisibleLayers, { import getVisibleLayers, {
itemAppearanceFragmentForGetVisibleLayers, itemAppearanceFragmentForGetVisibleLayers,
petAppearanceFragmentForGetVisibleLayers, petAppearanceFragmentForGetVisibleLayers,
} from "./getVisibleLayers"; } from "../components/getVisibleLayers";
import { useAltStyle } from "../loaders/alt-styles";
/** /**
* useOutfitAppearance downloads the outfit's appearance data, and returns * useOutfitAppearance downloads the outfit's appearance data, and returns
* visibleLayers for rendering. * visibleLayers for rendering.
*/ */
export default function useOutfitAppearance(outfitState) { export default function useOutfitAppearance(outfitState) {
const { wornItemIds, speciesId, colorId, pose, altStyleId, appearanceId } = const { wornItemIds, speciesId, colorId, pose, appearanceId } = outfitState;
outfitState;
// We split this query out from the other one, so that we can HTTP cache it. // We split this query out from the other one, so that we can HTTP cache it.
// //
@ -105,13 +102,7 @@ export default function useOutfitAppearance(outfitState) {
}, },
); );
const { const petAppearance = data1?.petAppearance;
isLoading: loading3,
error: error3,
data: altStyle,
} = useAltStyle(altStyleId, speciesId);
const petAppearance = altStyle?.appearance ?? data1?.petAppearance;
const items = data2?.items; const items = data2?.items;
const itemAppearances = React.useMemo( const itemAppearances = React.useMemo(
() => (items || []).map((i) => i.appearance), () => (items || []).map((i) => i.appearance),
@ -125,8 +116,8 @@ export default function useOutfitAppearance(outfitState) {
const bodyId = petAppearance?.bodyId; const bodyId = petAppearance?.bodyId;
return { return {
loading: loading1 || loading2 || loading3, loading: loading1 || loading2,
error: error1 || error2 || error3, error: error1 || error2,
petAppearance, petAppearance,
items: items || [], items: items || [],
itemAppearances, itemAppearances,

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#3B88C3" d="M14.57 27.673c2.814-1.692 6.635-3.807 9.899-7.071 7.03-7.029 12.729-16.97 11.314-18.385C34.369.803 24.428 6.502 17.398 13.531c-3.265 3.265-5.379 7.085-7.071 9.899l4.243 4.243z"/><path fill="#C1694F" d="M.428 34.744s7.071 1.414 12.021-3.536c2.121-2.121 2.121-4.949 2.121-4.949l-2.829-2.829s-3.535.708-4.95 2.122c-1.414 1.414-2.518 4.232-2.888 5.598-.676 2.502-3.475 3.594-3.475 3.594z"/><path fill="#CCD6DD" d="M17.882 25.328l-5.168-5.168c-.391-.391-.958-.326-1.27.145l-1.123 1.705c-.311.471-.271 1.142.087 1.501l4.122 4.123c.358.358 1.03.397 1.501.087l1.705-1.124c.472-.311.536-.878.146-1.269z"/><path fill="#A0041E" d="M11.229 32.26c-1.191.769-1.826.128-1.609-.609.221-.751-.12-1.648-1.237-1.414-1.117.233-1.856-.354-1.503-1.767.348-1.393-1.085-1.863-1.754-.435-.582 1.16-1.017 2.359-1.222 3.115-.677 2.503-3.476 3.595-3.476 3.595s5.988 1.184 10.801-2.485z"/></svg>

After

Width:  |  Height:  |  Size: 950 B

View file

@ -8,17 +8,6 @@ export function useAltStylesForSpecies(speciesId, options = {}) {
}); });
} }
// NOTE: This is actually just a wrapper for `useAltStylesForSpecies`, to share
// the same cache key!
export function useAltStyle(id, speciesId, options = {}) {
const query = useAltStylesForSpecies(speciesId, options);
return {
...query,
data: query.data?.find((s) => s.id === id) ?? null,
};
}
async function loadAltStylesForSpecies(speciesId) { async function loadAltStylesForSpecies(speciesId) {
const res = await fetch( const res = await fetch(
`/species/${encodeURIComponent(speciesId)}/alt-styles.json`, `/species/${encodeURIComponent(speciesId)}/alt-styles.json`,
@ -39,44 +28,11 @@ function normalizeAltStyles(altStylesData) {
function normalizeAltStyle(altStyleData) { function normalizeAltStyle(altStyleData) {
return { return {
id: String(altStyleData.id), id: altStyleData.id,
speciesId: String(altStyleData.species_id), speciesId: altStyleData.species_id,
colorId: String(altStyleData.color_id), colorId: altStyleData.color_id,
bodyId: String(altStyleData.body_id), bodyId: altStyleData.body_id,
seriesName: altStyleData.series_name,
adjectiveName: altStyleData.adjective_name, adjectiveName: altStyleData.adjective_name,
thumbnailUrl: altStyleData.thumbnail_url, thumbnailUrl: altStyleData.thumbnail_url,
// This matches the PetAppearanceForOutfitPreview GQL fragment!
appearance: {
bodyId: String(altStyleData.body_id),
pose: "UNKNOWN",
isGlitched: false,
species: { id: String(altStyleData.species_id) },
color: { id: String(altStyleData.species_id) },
layers: altStyleData.swf_assets.map(normalizeSwfAssetToLayer),
restrictedZones: [],
},
};
}
function normalizeSwfAssetToLayer(swfAssetData) {
return {
id: String(swfAssetData.id),
zone: {
id: String(swfAssetData.zone.id),
depth: swfAssetData.zone.depth,
label: swfAssetData.zone.label,
},
bodyId: swfAssetData.body_id,
knownGlitches: [], // TODO
// HACK: We're just simplifying this adapter, but it would be better to
// actually check what file formats the manifest says!
// TODO: For example, these do generally have SVGs, we could use them!
svgUrl: null,
canvasMovieLibraryUrl: null,
imageUrl: swfAssetData.image_url,
swfUrl: swfAssetData.url,
}; };
} }

View file

@ -6,25 +6,13 @@ class AltStyle < ApplicationRecord
has_many :swf_assets, through: :parent_swf_asset_relationships has_many :swf_assets, through: :parent_swf_asset_relationships
has_many :contributions, as: :contributed, inverse_of: :contributed has_many :contributions, as: :contributed, inverse_of: :contributed
SERIES_ID_RANGES = {
nostalgic: (87249..87503)
}
def name def name
I18n.translate('pet_types.human_name', color_human_name: color.human_name, I18n.translate('pet_types.human_name', color_human_name: color.human_name,
species_human_name: species.human_name) species_human_name: species.human_name)
end end
def series_name
if SERIES_ID_RANGES[:nostalgic].include?(id)
"Nostalgic"
else
"???"
end
end
def adjective_name def adjective_name
"#{series_name} #{color.human_name}" "Nostalgic #{color.human_name}"
end end
def thumbnail_url def thumbnail_url

View file

@ -46,7 +46,7 @@ class SwfAsset < ApplicationRecord
size_key = size.join('x') size_key = size.join('x')
image_dir = "#{self['type']}/#{partition_path}#{self.remote_id}" image_dir = "#{self['type']}/#{partition_path}#{self.remote_id}"
"https://#{host}/#{image_dir}/#{size_key}.png?#{image_version}" "//#{host}/#{image_dir}/#{size_key}.png?#{image_version}"
end end
def images def images