impress-2020/src/useOutfitState.js

285 lines
8.5 KiB
JavaScript
Raw Normal View History

2020-04-21 20:46:53 -07:00
import React from "react";
2020-04-24 18:39:38 -07:00
import gql from "graphql-tag";
import produce, { enableMapSet } from "immer";
2020-04-24 21:17:03 -07:00
import { useQuery, useApolloClient } from "@apollo/react-hooks";
2020-04-21 20:46:53 -07:00
2020-04-24 21:17:03 -07:00
import { itemAppearanceFragment } from "./OutfitPreview";
2020-04-21 20:46:53 -07:00
2020-04-24 18:39:38 -07:00
enableMapSet();
2020-04-21 20:46:53 -07:00
2020-04-24 18:39:38 -07:00
function useOutfitState() {
const apolloClient = useApolloClient();
2020-04-24 19:16:24 -07:00
const [state, dispatchToOutfit] = React.useReducer(
outfitStateReducer(apolloClient),
{
2020-04-24 23:29:26 -07:00
name: "Zafara Agent (roopal27)",
2020-04-24 19:16:24 -07:00
wornItemIds: new Set([
"38913",
"38911",
"38912",
"37375",
"48313",
"37229",
"43014",
"43397",
]),
closetedItemIds: new Set(["74166", "68626", "40319"]),
speciesId: "54", // Starry
colorId: "75", // Zafara
}
);
2020-04-21 20:46:53 -07:00
2020-04-25 07:22:03 -07:00
React.useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("species")) {
dispatchToOutfit({
type: "reset",
name: urlParams.get("name"),
speciesId: urlParams.get("species"),
colorId: urlParams.get("color"),
wornItemIds: urlParams.getAll("objects[]"),
closetedItemIds: urlParams.getAll("closet[]"),
});
}
window.history.replaceState(null, "", window.location.href.split("?")[0]);
});
2020-04-24 23:29:26 -07:00
const { name, speciesId, colorId } = state;
2020-04-21 20:46:53 -07:00
2020-04-24 18:39:38 -07:00
// It's more convenient to manage these as a Set in state, but most callers
// will find it more convenient to access them as arrays! e.g. for `.map()`
const wornItemIds = Array.from(state.wornItemIds);
const closetedItemIds = Array.from(state.closetedItemIds);
2020-04-21 20:46:53 -07:00
2020-04-24 18:39:38 -07:00
const allItemIds = [...state.wornItemIds, ...state.closetedItemIds];
2020-04-24 21:17:03 -07:00
const { loading, error, data } = useQuery(
gql`
query($allItemIds: [ID!]!, $speciesId: ID!, $colorId: ID!) {
items(ids: $allItemIds) {
# TODO: De-dupe this from SearchPanel?
id
name
thumbnailUrl
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
# This enables us to quickly show the item when the user clicks it!
...AppearanceForOutfitPreview
# This is used to group items by zone, and to detect conflicts when
# wearing a new item.
layers {
zone {
id
label
}
}
}
}
}
${itemAppearanceFragment}
`,
{ variables: { allItemIds, speciesId, colorId } }
2020-04-21 20:46:53 -07:00
);
2020-04-24 21:17:03 -07:00
const items = (data && data.items) || [];
const itemsById = {};
for (const item of items) {
itemsById[item.id] = item;
}
2020-04-22 14:55:12 -07:00
const zonesAndItems = getZonesAndItems(
itemsById,
wornItemIds,
closetedItemIds
2020-04-21 20:46:53 -07:00
);
2020-04-24 19:16:24 -07:00
const outfitState = {
zonesAndItems,
2020-04-24 23:29:26 -07:00
name,
2020-04-24 19:16:24 -07:00
wornItemIds,
2020-04-25 07:22:03 -07:00
closetedItemIds,
2020-04-24 19:16:24 -07:00
allItemIds,
speciesId,
colorId,
};
2020-04-24 18:39:38 -07:00
2020-04-24 19:16:24 -07:00
return { loading, error, outfitState, dispatchToOutfit };
2020-04-24 18:39:38 -07:00
}
const outfitStateReducer = (apolloClient) => (baseState, action) => {
switch (action.type) {
2020-04-24 23:29:26 -07:00
case "rename":
return { ...baseState, name: action.outfitName };
2020-04-25 04:33:05 -07:00
case "changeColor":
return { ...baseState, colorId: action.colorId };
case "changeSpecies":
return { ...baseState, speciesId: action.speciesId };
2020-04-24 18:39:38 -07:00
case "wearItem":
return produce(baseState, (state) => {
2020-04-24 20:19:26 -07:00
// A hack to work around https://github.com/immerjs/immer/issues/586
state.wornItemIds.add("fake-id-immer#586").delete("fake-id-immer#586");
2020-04-24 19:16:24 -07:00
const { wornItemIds, closetedItemIds } = state;
2020-04-24 18:39:38 -07:00
const { itemId } = action;
// Move conflicting items to the closet.
//
// We do this by looking them up in the Apollo Cache, which is going to
// include the relevant item data because the `useOutfitState` hook
// queries for it!
//
// (It could be possible to mess up the timing by taking an action
// while worn items are still partially loading, but I think it would
// require a pretty weird action sequence to make that happen... like,
// doing a search and it loads before the worn item data does? Anyway,
// Apollo will throw in that case, which should just essentially reject
// the action.)
const conflictingIds = findItemConflicts(itemId, state, apolloClient);
for (const conflictingId of conflictingIds) {
wornItemIds.delete(conflictingId);
closetedItemIds.add(conflictingId);
}
2020-04-24 20:19:26 -07:00
// Move this item from the closet to the worn set.
closetedItemIds.delete(itemId);
2020-04-24 18:39:38 -07:00
wornItemIds.add(itemId);
});
2020-04-24 20:19:26 -07:00
case "unwearItem":
return produce(baseState, (state) => {
const { wornItemIds, closetedItemIds } = state;
const { itemId } = action;
// Move this item from the worn set to the closet.
wornItemIds.delete(itemId);
closetedItemIds.add(itemId);
});
case "removeItem":
return produce(baseState, (state) => {
const { wornItemIds, closetedItemIds } = state;
const { itemId } = action;
// Remove this item from both the worn set and the closet.
wornItemIds.delete(itemId);
closetedItemIds.delete(itemId);
});
2020-04-25 05:29:27 -07:00
case "reset":
const { name, speciesId, colorId, wornItemIds, closetedItemIds } = action;
return {
name,
2020-04-25 07:22:03 -07:00
speciesId: speciesId ? String(speciesId) : baseState.speciesId,
colorId: colorId ? String(colorId) : baseState.colorId,
wornItemIds: wornItemIds
? new Set(wornItemIds.map(String))
: baseState.wornItemIds,
closetedItemIds: closetedItemIds
? new Set(closetedItemIds.map(String))
: baseState.closetedItemIds,
2020-04-25 05:29:27 -07:00
};
2020-04-24 18:39:38 -07:00
default:
2020-04-25 04:33:05 -07:00
throw new Error(`unexpected action ${JSON.stringify(action)}`);
2020-04-24 18:39:38 -07:00
}
};
function findItemConflicts(itemIdToAdd, state, apolloClient) {
const { wornItemIds, speciesId, colorId } = state;
const { items } = apolloClient.readQuery({
query: gql`
query($itemIds: [ID!]!, $speciesId: ID!, $colorId: ID!) {
items(ids: $itemIds) {
id
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
layers {
zone {
id
}
}
restrictedZones {
id
}
2020-04-24 18:39:38 -07:00
}
}
}
`,
variables: {
itemIds: [itemIdToAdd, ...wornItemIds],
speciesId,
colorId,
},
});
const itemToAdd = items.find((i) => i.id === itemIdToAdd);
if (!itemToAdd.appearanceOn) {
return [];
}
const itemToAddZoneIds = [
...itemToAdd.appearanceOn.layers.map((l) => l.zone.id),
...itemToAdd.appearanceOn.restrictedZones.map((z) => z.id),
];
2020-04-24 18:39:38 -07:00
const wornItems = Array.from(wornItemIds).map((id) =>
items.find((i) => i.id === id)
);
const conflictingIds = [];
for (const wornItem of wornItems) {
if (!wornItem.appearanceOn) {
continue;
}
const wornItemZoneIds = [
...wornItem.appearanceOn.layers.map((l) => l.zone.id),
...wornItem.appearanceOn.restrictedZones.map((z) => z.id),
];
2020-04-24 18:39:38 -07:00
const hasConflict = wornItemZoneIds.some((zid) =>
itemToAddZoneIds.includes(zid)
);
if (hasConflict) {
conflictingIds.push(wornItem.id);
}
}
2020-04-22 14:55:12 -07:00
2020-04-24 18:39:38 -07:00
return conflictingIds;
2020-04-22 14:55:12 -07:00
}
2020-04-24 19:16:24 -07:00
// TODO: Get this out of here, tbh...
2020-04-22 14:55:12 -07:00
function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
const wornItems = wornItemIds.map((id) => itemsById[id]).filter((i) => i);
const closetedItems = closetedItemIds
.map((id) => itemsById[id])
.filter((i) => i);
// We use zone label here, rather than ID, because some zones have the same
// label and we *want* to over-simplify that in this UI. (e.g. there are
// multiple Hat zones, and some items occupy different ones, but mostly let's
// just group them and if they don't conflict then all the better!)
2020-04-21 20:46:53 -07:00
const allItems = [...wornItems, ...closetedItems];
const itemsByZoneLabel = new Map();
2020-04-24 19:16:24 -07:00
for (const item of allItems) {
if (!item.appearanceOn) {
continue;
}
2020-04-24 19:16:24 -07:00
for (const layer of item.appearanceOn.layers) {
const zoneLabel = layer.zone.label;
2020-04-24 19:16:24 -07:00
if (!itemsByZoneLabel.has(zoneLabel)) {
itemsByZoneLabel.set(zoneLabel, []);
2020-04-24 19:16:24 -07:00
}
itemsByZoneLabel.get(zoneLabel).push(item);
2020-04-24 19:16:24 -07:00
}
}
const zonesAndItems = Array.from(itemsByZoneLabel.entries()).map(
([zoneLabel, items]) => ({
zoneLabel: zoneLabel,
2020-04-24 19:16:24 -07:00
items: [...items].sort((a, b) => a.name.localeCompare(b.name)),
})
);
zonesAndItems.sort((a, b) => a.zoneLabel.localeCompare(b.zoneLabel));
2020-04-21 20:46:53 -07:00
2020-04-22 14:55:12 -07:00
return zonesAndItems;
2020-04-21 20:46:53 -07:00
}
export default useOutfitState;