Compare commits

...

5 commits

Author SHA1 Message Date
c2de6f7167 Display alt styles in outfit editor when selected
Yay it works(*)! But two major missing pieces:

- Outfit saving doesn't persist it at all
- Item compatibility is unaffected: items will still appear in search
  and in the preview, even when they don't fit anymore.
2024-01-30 07:01:03 -08:00
3ebbfc4967 Move alt style state into the outfit state
This still doesn't _do_ anything, except that you can see the URL
change when you switch between styles. Just a step forward is all!
2024-01-30 06:21:32 -08:00
741d52175b Open the pose picker to Styles if there's an Alt Style applied 2024-01-30 06:04:35 -08:00
8e5939e408 Show alt style name in the pose picker button when selected
To help with space, I'm just showing the word "Nostalgic" (or "???" if
it's from a series we don't recognize, this is hardcoded by ID), and
trusting that from context it will be obvious that it's the "Nostalgic
Faerie" case or whatever. (Moreover, in both the button and the select
we're omitting the species name, by similar reasoning!)

Note that this _still_ doesn't actually apply the style to the outfit
whatsoever; this is all just local state as we're continuing to play
with UI concepts. Actually applying it is probably next though! (Though
there's a couple more UI things I want to do, like some affordances to
clarify that a Style is applied and that Expression changes won't work.)
2024-01-30 05:55:19 -08:00
33bcabab83 Overhaul pose picker button to have text now, for all users
This one isn't reserved just for Support users! I think this button
stands out a lot more with the text, and I want people to be able to
discover it!
2024-01-30 05:38:19 -08:00
12 changed files with 173 additions and 46 deletions

View file

@ -11,8 +11,9 @@ 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.as_json( render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
methods: [:adjective_name, :thumbnail_url], include: {swf_assets: {include: [:zone], methods: [:image_url]}},
methods: [:series_name, :adjective_name, :thumbnail_url],
) )
} }
end end

View file

@ -215,12 +215,6 @@ 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
@ -234,11 +228,12 @@ function OutfitControls({
/> />
</DarkMode> </DarkMode>
</Box> </Box>
<Flex flex="1 1 0" align="center" pl="2"> <Flex flex="0 0 auto" 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,6 +23,7 @@ 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";
@ -65,6 +66,7 @@ function PosePicker({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
appearanceId, appearanceId,
dispatchToOutfit, dispatchToOutfit,
onLockFocus, onLockFocus,
@ -86,8 +88,24 @@ 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(() => {
@ -144,10 +162,6 @@ 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) {
@ -158,24 +172,40 @@ function PosePicker({
(p) => p.isAvailable && STANDARD_POSES.includes(p.pose), (p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
).length; ).length;
const onChange = (e) => { const onChangePose = (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={onLockFocus} onOpen={() => {
onClose={onUnlockFocus} setIsOpen(true);
onLockFocus();
}}
onClose={() => {
setIsOpen(false);
onUnlockFocus();
}}
initialFocusRef={initialFocusRef} initialFocusRef={initialFocusRef}
isLazy isLazy
lazyBehavior="keepMounted" lazyBehavior="keepMounted"
> >
{({ isOpen }) => ( {() => (
<> <>
<PopoverTrigger> <PopoverTrigger>
<PosePickerButton pose={pose} isOpen={isOpen} {...props} /> <PosePickerButton
pose={pose}
altStyle={altStyle}
isOpen={isOpen}
loading={loading}
{...props}
/>
</PopoverTrigger> </PopoverTrigger>
<Portal> <Portal>
<PopoverContent> <PopoverContent>
@ -186,6 +216,8 @@ 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
@ -213,7 +245,7 @@ function PosePicker({
<> <>
<PosePickerTable <PosePickerTable
poseInfos={poseInfos} poseInfos={poseInfos}
onChange={onChange} onChange={onChangePose}
initialFocusRef={initialFocusRef} initialFocusRef={initialFocusRef}
/> />
{numStandardPoses == 0 && ( {numStandardPoses == 0 && (
@ -232,7 +264,9 @@ 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 />
@ -248,9 +282,12 @@ function PosePicker({
); );
} }
function PosePickerButton({ pose, isOpen, ...props }, ref) { function PosePickerButton({ pose, altStyle, isOpen, loading, ...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 }) => (
@ -265,16 +302,28 @@ function PosePickerButton({ pose, isOpen, ...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;
transition: border-color 0.2s !important; color: ${theme.colors.gray["300"]};
padding-inline: 0.75em; cursor: ${loading ? "wait" : "pointer"} !important;
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 {
@ -286,11 +335,11 @@ function PosePickerButton({ pose, isOpen, ...props }, ref) {
{...props} {...props}
ref={ref} ref={ref}
> >
<EmojiImage src={getIcon(pose)} alt="" /> <EmojiImage src={icon} alt="Style" />
<SupportOnly> <Box width=".5em" />
<Box width=".5em" /> {label}
{getLabel(pose)} <Box width=".5em" />
</SupportOnly> <ChevronDownIcon />
</Button> </Button>
)} )}
</ClassNames> </ClassNames>
@ -572,9 +621,12 @@ function PosePickerEmptyExplanation() {
); );
} }
function StyleSelect({ altStyles, initialFocusRef }) { function StyleSelect({
const [selectedStyleId, setSelectedStyleId] = React.useState(null); selectedStyleId,
altStyles,
onChange,
initialFocusRef,
}) {
const defaultStyle = { id: null, adjectiveName: "Default" }; const defaultStyle = { id: null, adjectiveName: "Default" };
const styles = [defaultStyle, ...altStyles]; const styles = [defaultStyle, ...altStyles];
@ -593,7 +645,7 @@ function StyleSelect({ altStyles, initialFocusRef }) {
key={altStyle.id} key={altStyle.id}
altStyle={altStyle} altStyle={altStyle}
checked={selectedStyleId === altStyle.id} checked={selectedStyleId === altStyle.id}
onChange={setSelectedStyleId} onChange={onChange}
inputRef={selectedStyleId === altStyle.id ? initialFocusRef : null} inputRef={selectedStyleId === altStyle.id ? initialFocusRef : null}
/> />
))} ))}

View file

@ -24,6 +24,7 @@ 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,11 +413,12 @@ function ItemSupportPetCompatibilityRuleFields({
*/ */
function ItemSupportAppearanceLayers({ item }) { function ItemSupportAppearanceLayers({ item }) {
const outfitState = React.useContext(OutfitStateContext); const outfitState = React.useContext(OutfitStateContext);
const { speciesId, colorId, pose, appearanceId } = outfitState; const { speciesId, colorId, pose, altStyleId, 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,7 +113,8 @@ 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, appearanceId } = outfitState; const { id, name, speciesId, colorId, pose, altStyleId, appearanceId } =
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];
@ -236,6 +237,7 @@ function useOutfitState() {
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
appearanceId, appearanceId,
url, url,
@ -351,6 +353,10 @@ 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:
@ -417,6 +423,7 @@ 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[]")),
@ -640,6 +647,7 @@ function buildOutfitQueryString(outfitState) {
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
appearanceId, appearanceId,
wornItemIds, wornItemIds,
closetedItemIds, closetedItemIds,
@ -657,6 +665,9 @@ 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,6 +52,7 @@ export function useOutfitPreview({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
wornItemIds, wornItemIds,
appearanceId = null, appearanceId = null,
isLoading = false, isLoading = false,
@ -68,6 +69,7 @@ export function useOutfitPreview({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId,
appearanceId, appearanceId,
wornItemIds, wornItemIds,
}); });

View file

@ -1,17 +1,20 @@
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 "../components/getVisibleLayers"; } from "./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, appearanceId } = outfitState; const { wornItemIds, speciesId, colorId, pose, altStyleId, appearanceId } =
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.
// //
@ -102,7 +105,13 @@ export default function useOutfitAppearance(outfitState) {
}, },
); );
const petAppearance = data1?.petAppearance; const {
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),
@ -116,8 +125,8 @@ export default function useOutfitAppearance(outfitState) {
const bodyId = petAppearance?.bodyId; const bodyId = petAppearance?.bodyId;
return { return {
loading: loading1 || loading2, loading: loading1 || loading2 || loading3,
error: error1 || error2, error: error1 || error2 || error3,
petAppearance, petAppearance,
items: items || [], items: items || [],
itemAppearances, itemAppearances,

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 950 B

View file

@ -8,6 +8,17 @@ 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`,
@ -28,11 +39,44 @@ function normalizeAltStyles(altStylesData) {
function normalizeAltStyle(altStyleData) { function normalizeAltStyle(altStyleData) {
return { return {
id: altStyleData.id, id: String(altStyleData.id),
speciesId: altStyleData.species_id, speciesId: String(altStyleData.species_id),
colorId: altStyleData.color_id, colorId: String(altStyleData.color_id),
bodyId: altStyleData.body_id, bodyId: String(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,13 +6,25 @@ 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
"Nostalgic #{color.human_name}" "#{series_name} #{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}"
"//#{host}/#{image_dir}/#{size_key}.png?#{image_version}" "https://#{host}/#{image_dir}/#{size_key}.png?#{image_version}"
end end
def images def images