Compare commits
5 commits
4025dcd968
...
c2de6f7167
Author | SHA1 | Date | |
---|---|---|---|
c2de6f7167 | |||
3ebbfc4967 | |||
741d52175b | |||
8e5939e408 | |||
33bcabab83 |
12 changed files with 173 additions and 46 deletions
|
@ -11,8 +11,9 @@ class AltStylesController < ApplicationController
|
|||
respond_to do |format|
|
||||
format.html { render }
|
||||
format.json {
|
||||
render json: @alt_styles.as_json(
|
||||
methods: [:adjective_name, :thumbnail_url],
|
||||
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],
|
||||
)
|
||||
}
|
||||
end
|
||||
|
|
|
@ -215,12 +215,6 @@ 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
|
||||
|
@ -234,11 +228,12 @@ function OutfitControls({
|
|||
/>
|
||||
</DarkMode>
|
||||
</Box>
|
||||
<Flex flex="1 1 0" align="center" pl="2">
|
||||
<Flex flex="0 0 auto" align="center" pl="2">
|
||||
<PosePicker
|
||||
speciesId={outfitState.speciesId}
|
||||
colorId={outfitState.colorId}
|
||||
pose={outfitState.pose}
|
||||
altStyleId={outfitState.altStyleId}
|
||||
appearanceId={outfitState.appearanceId}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
onLockFocus={onLockFocus}
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
useToast,
|
||||
useToken,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChevronDownIcon } from "@chakra-ui/icons";
|
||||
import { loadable } from "../util";
|
||||
|
||||
import { petAppearanceFragment } from "../components/useOutfitAppearance";
|
||||
|
@ -65,6 +66,7 @@ function PosePicker({
|
|||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
appearanceId,
|
||||
dispatchToOutfit,
|
||||
onLockFocus,
|
||||
|
@ -86,8 +88,24 @@ 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(() => {
|
||||
|
@ -144,10 +162,6 @@ 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) {
|
||||
|
@ -158,24 +172,40 @@ function PosePicker({
|
|||
(p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
|
||||
).length;
|
||||
|
||||
const onChange = (e) => {
|
||||
const onChangePose = (e) => {
|
||||
dispatchToOutfit({ type: "setPose", pose: e.target.value });
|
||||
};
|
||||
|
||||
const onChangeStyle = (altStyleId) => {
|
||||
dispatchToOutfit({ type: "setStyle", altStyleId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement={placement}
|
||||
returnFocusOnClose
|
||||
onOpen={onLockFocus}
|
||||
onClose={onUnlockFocus}
|
||||
onOpen={() => {
|
||||
setIsOpen(true);
|
||||
onLockFocus();
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
onUnlockFocus();
|
||||
}}
|
||||
initialFocusRef={initialFocusRef}
|
||||
isLazy
|
||||
lazyBehavior="keepMounted"
|
||||
>
|
||||
{({ isOpen }) => (
|
||||
{() => (
|
||||
<>
|
||||
<PopoverTrigger>
|
||||
<PosePickerButton pose={pose} isOpen={isOpen} {...props} />
|
||||
<PosePickerButton
|
||||
pose={pose}
|
||||
altStyle={altStyle}
|
||||
isOpen={isOpen}
|
||||
loading={loading}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent>
|
||||
|
@ -186,6 +216,8 @@ 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
|
||||
|
@ -213,7 +245,7 @@ function PosePicker({
|
|||
<>
|
||||
<PosePickerTable
|
||||
poseInfos={poseInfos}
|
||||
onChange={onChange}
|
||||
onChange={onChangePose}
|
||||
initialFocusRef={initialFocusRef}
|
||||
/>
|
||||
{numStandardPoses == 0 && (
|
||||
|
@ -232,7 +264,9 @@ function PosePicker({
|
|||
</TabPanel>
|
||||
<TabPanel paddingX="4" paddingY="0">
|
||||
<StyleSelect
|
||||
selectedStyleId={altStyleId}
|
||||
altStyles={altStyles}
|
||||
onChange={onChangeStyle}
|
||||
initialFocusRef={initialFocusRef}
|
||||
/>
|
||||
<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 icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
|
||||
const label = altStyle != null ? altStyle.seriesName : getLabel(pose);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
|
@ -265,16 +302,28 @@ function PosePickerButton({ pose, isOpen, ...props }, ref) {
|
|||
outline="initial"
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
minWidth="12ch"
|
||||
disabled={loading}
|
||||
className={cx(
|
||||
css`
|
||||
border: 1px solid transparent !important;
|
||||
transition: border-color 0.2s !important;
|
||||
padding-inline: 0.75em;
|
||||
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;
|
||||
|
||||
&: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 {
|
||||
|
@ -286,11 +335,11 @@ function PosePickerButton({ pose, isOpen, ...props }, ref) {
|
|||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<EmojiImage src={getIcon(pose)} alt="" />
|
||||
<SupportOnly>
|
||||
<EmojiImage src={icon} alt="Style" />
|
||||
<Box width=".5em" />
|
||||
{getLabel(pose)}
|
||||
</SupportOnly>
|
||||
{label}
|
||||
<Box width=".5em" />
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
)}
|
||||
</ClassNames>
|
||||
|
@ -572,9 +621,12 @@ function PosePickerEmptyExplanation() {
|
|||
);
|
||||
}
|
||||
|
||||
function StyleSelect({ altStyles, initialFocusRef }) {
|
||||
const [selectedStyleId, setSelectedStyleId] = React.useState(null);
|
||||
|
||||
function StyleSelect({
|
||||
selectedStyleId,
|
||||
altStyles,
|
||||
onChange,
|
||||
initialFocusRef,
|
||||
}) {
|
||||
const defaultStyle = { id: null, adjectiveName: "Default" };
|
||||
|
||||
const styles = [defaultStyle, ...altStyles];
|
||||
|
@ -593,7 +645,7 @@ function StyleSelect({ altStyles, initialFocusRef }) {
|
|||
key={altStyle.id}
|
||||
altStyle={altStyle}
|
||||
checked={selectedStyleId === altStyle.id}
|
||||
onChange={setSelectedStyleId}
|
||||
onChange={onChange}
|
||||
inputRef={selectedStyleId === altStyle.id ? initialFocusRef : null}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -24,6 +24,7 @@ function WardrobePreviewAndControls({
|
|||
speciesId: outfitState.speciesId,
|
||||
colorId: outfitState.colorId,
|
||||
pose: outfitState.pose,
|
||||
altStyleId: outfitState.altStyleId,
|
||||
appearanceId: outfitState.appearanceId,
|
||||
wornItemIds: outfitState.wornItemIds,
|
||||
onChangeHasAnimations: setHasAnimations,
|
||||
|
|
|
@ -413,11 +413,12 @@ function ItemSupportPetCompatibilityRuleFields({
|
|||
*/
|
||||
function ItemSupportAppearanceLayers({ item }) {
|
||||
const outfitState = React.useContext(OutfitStateContext);
|
||||
const { speciesId, colorId, pose, appearanceId } = outfitState;
|
||||
const { speciesId, colorId, pose, altStyleId, appearanceId } = outfitState;
|
||||
const { error, visibleLayers } = useOutfitAppearance({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
appearanceId,
|
||||
wornItemIds: [item.id],
|
||||
});
|
||||
|
|
|
@ -113,7 +113,8 @@ 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, appearanceId } = outfitState;
|
||||
const { id, name, speciesId, colorId, pose, altStyleId, appearanceId } =
|
||||
outfitState;
|
||||
const wornItemIds = Array.from(outfitState.wornItemIds);
|
||||
const closetedItemIds = Array.from(outfitState.closetedItemIds);
|
||||
const allItemIds = [...wornItemIds, ...closetedItemIds];
|
||||
|
@ -236,6 +237,7 @@ function useOutfitState() {
|
|||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
appearanceId,
|
||||
url,
|
||||
|
||||
|
@ -351,6 +353,10 @@ 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:
|
||||
|
@ -417,6 +423,7 @@ 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[]")),
|
||||
|
@ -640,6 +647,7 @@ function buildOutfitQueryString(outfitState) {
|
|||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
appearanceId,
|
||||
wornItemIds,
|
||||
closetedItemIds,
|
||||
|
@ -657,6 +665,9 @@ 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.
|
||||
|
|
|
@ -52,6 +52,7 @@ export function useOutfitPreview({
|
|||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
wornItemIds,
|
||||
appearanceId = null,
|
||||
isLoading = false,
|
||||
|
@ -68,6 +69,7 @@ export function useOutfitPreview({
|
|||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
appearanceId,
|
||||
wornItemIds,
|
||||
});
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
import React from "react";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
|
||||
import getVisibleLayers, {
|
||||
itemAppearanceFragmentForGetVisibleLayers,
|
||||
petAppearanceFragmentForGetVisibleLayers,
|
||||
} from "../components/getVisibleLayers";
|
||||
} from "./getVisibleLayers";
|
||||
import { useAltStyle } from "../loaders/alt-styles";
|
||||
|
||||
/**
|
||||
* useOutfitAppearance downloads the outfit's appearance data, and returns
|
||||
* visibleLayers for rendering.
|
||||
*/
|
||||
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.
|
||||
//
|
||||
|
@ -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 itemAppearances = React.useMemo(
|
||||
() => (items || []).map((i) => i.appearance),
|
||||
|
@ -116,8 +125,8 @@ export default function useOutfitAppearance(outfitState) {
|
|||
const bodyId = petAppearance?.bodyId;
|
||||
|
||||
return {
|
||||
loading: loading1 || loading2,
|
||||
error: error1 || error2,
|
||||
loading: loading1 || loading2 || loading3,
|
||||
error: error1 || error2 || error3,
|
||||
petAppearance,
|
||||
items: items || [],
|
||||
itemAppearances,
|
||||
|
|
|
@ -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 |
|
@ -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) {
|
||||
const res = await fetch(
|
||||
`/species/${encodeURIComponent(speciesId)}/alt-styles.json`,
|
||||
|
@ -28,11 +39,44 @@ function normalizeAltStyles(altStylesData) {
|
|||
|
||||
function normalizeAltStyle(altStyleData) {
|
||||
return {
|
||||
id: altStyleData.id,
|
||||
speciesId: altStyleData.species_id,
|
||||
colorId: altStyleData.color_id,
|
||||
bodyId: altStyleData.body_id,
|
||||
id: String(altStyleData.id),
|
||||
speciesId: String(altStyleData.species_id),
|
||||
colorId: String(altStyleData.color_id),
|
||||
bodyId: String(altStyleData.body_id),
|
||||
seriesName: altStyleData.series_name,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,13 +6,25 @@ 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
|
||||
"Nostalgic #{color.human_name}"
|
||||
"#{series_name} #{color.human_name}"
|
||||
end
|
||||
|
||||
def thumbnail_url
|
||||
|
|
|
@ -46,7 +46,7 @@ class SwfAsset < ApplicationRecord
|
|||
size_key = size.join('x')
|
||||
|
||||
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
|
||||
|
||||
def images
|
||||
|
|
Loading…
Reference in a new issue