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

View file

@ -215,6 +215,12 @@ function OutfitControls({
* 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!
*/}
<Flex
flex="1 1 0"
paddingRight="3"
align="center"
justify="flex-end"
/>
<Box flex="0 0 auto">
<DarkMode>
<SpeciesColorPicker
@ -228,12 +234,11 @@ function OutfitControls({
/>
</DarkMode>
</Box>
<Flex flex="0 0 auto" align="center" pl="2">
<Flex flex="1 1 0" align="center" pl="2">
<PosePicker
speciesId={outfitState.speciesId}
colorId={outfitState.colorId}
pose={outfitState.pose}
altStyleId={outfitState.altStyleId}
appearanceId={outfitState.appearanceId}
dispatchToOutfit={dispatchToOutfit}
onLockFocus={onLockFocus}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,17 @@
import React from "react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import getVisibleLayers, {
itemAppearanceFragmentForGetVisibleLayers,
petAppearanceFragmentForGetVisibleLayers,
} from "./getVisibleLayers";
import { useAltStyle } from "../loaders/alt-styles";
} from "../components/getVisibleLayers";
/**
* useOutfitAppearance downloads the outfit's appearance data, and returns
* visibleLayers for rendering.
*/
export default function useOutfitAppearance(outfitState) {
const { wornItemIds, speciesId, colorId, pose, altStyleId, appearanceId } =
outfitState;
const { wornItemIds, speciesId, colorId, pose, appearanceId } = outfitState;
// 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 {
isLoading: loading3,
error: error3,
data: altStyle,
} = useAltStyle(altStyleId, speciesId);
const petAppearance = altStyle?.appearance ?? data1?.petAppearance;
const petAppearance = data1?.petAppearance;
const items = data2?.items;
const itemAppearances = React.useMemo(
() => (items || []).map((i) => i.appearance),
@ -125,8 +116,8 @@ export default function useOutfitAppearance(outfitState) {
const bodyId = petAppearance?.bodyId;
return {
loading: loading1 || loading2 || loading3,
error: error1 || error2 || error3,
loading: loading1 || loading2,
error: error1 || error2,
petAppearance,
items: items || [],
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) {
const res = await fetch(
`/species/${encodeURIComponent(speciesId)}/alt-styles.json`,
@ -39,44 +28,11 @@ function normalizeAltStyles(altStylesData) {
function normalizeAltStyle(altStyleData) {
return {
id: String(altStyleData.id),
speciesId: String(altStyleData.species_id),
colorId: String(altStyleData.color_id),
bodyId: String(altStyleData.body_id),
seriesName: altStyleData.series_name,
id: altStyleData.id,
speciesId: altStyleData.species_id,
colorId: altStyleData.color_id,
bodyId: altStyleData.body_id,
adjectiveName: altStyleData.adjective_name,
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 :contributions, as: :contributed, inverse_of: :contributed
SERIES_ID_RANGES = {
nostalgic: (87249..87503)
}
def name
I18n.translate('pet_types.human_name', color_human_name: color.human_name,
species_human_name: species.human_name)
end
def series_name
if SERIES_ID_RANGES[:nostalgic].include?(id)
"Nostalgic"
else
"???"
end
end
def adjective_name
"#{series_name} #{color.human_name}"
"Nostalgic #{color.human_name}"
end
def thumbnail_url

View file

@ -46,7 +46,7 @@ class SwfAsset < ApplicationRecord
size_key = size.join('x')
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
def images