diff --git a/apps/frontend/src/app/Components/CustomNumberInput.tsx b/apps/frontend/src/app/Components/CustomNumberInput.tsx index b26c2f0082..d953ed4ea2 100644 --- a/apps/frontend/src/app/Components/CustomNumberInput.tsx +++ b/apps/frontend/src/app/Components/CustomNumberInput.tsx @@ -10,6 +10,9 @@ export type CustomNumberInputProps = Omit & { disableNegative?: boolean } +/** + * @deprecated use `StyledInputBase` in `@genshin-optimizer/ui-common` + */ export const StyledInputBase = styled(InputBase)( ({ theme, color = 'primary' }) => ({ backgroundColor: theme.palette[color].main, @@ -37,6 +40,9 @@ const Wrapper = styled(Button)(({ theme }) => ({ })) // wrap the Input with this when using the input in a buttongroup +/** + * @deprecated use `CustomNumberInputButtonGroupWrapper` in `@genshin-optimizer/ui-common` + */ export function CustomNumberInputButtonGroupWrapper({ children, disableRipple, @@ -51,6 +57,9 @@ export function CustomNumberInputButtonGroupWrapper({ ) } +/** + * @deprecated use `CustomNumberInput` in `@genshin-optimizer/ui-common` + */ export default function CustomNumberInput({ value = 0, onChange, diff --git a/apps/sr-frontend/src/app/App.tsx b/apps/sr-frontend/src/app/App.tsx index 20dd712498..0e20b7767a 100644 --- a/apps/sr-frontend/src/app/App.tsx +++ b/apps/sr-frontend/src/app/App.tsx @@ -1,4 +1,8 @@ -import { CharacterProvider, DatabaseProvider } from '@genshin-optimizer/sr-ui' +import { + CharacterProvider, + DatabaseProvider, + RelicEditor, +} from '@genshin-optimizer/sr-ui' import { CssBaseline, Stack, @@ -21,6 +25,7 @@ export default function App() { + diff --git a/libs/sr-db/src/Database/DataManagers/RelicData.ts b/libs/sr-db/src/Database/DataManagers/RelicData.ts index f17730423c..5f2a5b9e6c 100644 --- a/libs/sr-db/src/Database/DataManagers/RelicData.ts +++ b/libs/sr-db/src/Database/DataManagers/RelicData.ts @@ -516,7 +516,7 @@ function parseSubstats( return defSub() if (key) { value = key.endsWith('_') - ? Math.round(value * 10) / 10 + ? Math.round(value * 1000) / 1000 : Math.round(value) const { low, high } = getSubstatRange(rarity, key) value = clamp(value, allowZeroSub ? 0 : low, high) diff --git a/libs/sr-db/src/Database/DataManagers/index.ts b/libs/sr-db/src/Database/DataManagers/index.ts new file mode 100644 index 0000000000..8d69e436cc --- /dev/null +++ b/libs/sr-db/src/Database/DataManagers/index.ts @@ -0,0 +1,6 @@ +export * from './BuildResultData' +export * from './BuildSettingData' +export * from './CharMetaData' +export * from './CharacterData' +export * from './LightConeData' +export * from './RelicData' diff --git a/libs/sr-db/src/Database/index.ts b/libs/sr-db/src/Database/index.ts index 79a45dbc02..7cbaf67511 100644 --- a/libs/sr-db/src/Database/index.ts +++ b/libs/sr-db/src/Database/index.ts @@ -1 +1,2 @@ +export * from './DataManagers' export * from './Database' diff --git a/libs/sr-ui/src/RelicEditor/RelicEditor.tsx b/libs/sr-ui/src/RelicEditor/RelicEditor.tsx new file mode 100644 index 0000000000..cf679261c7 --- /dev/null +++ b/libs/sr-ui/src/RelicEditor/RelicEditor.tsx @@ -0,0 +1,333 @@ +import { useForceUpdate } from '@genshin-optimizer/react-util' +import type { + RelicRarityKey, + RelicSetKey, + RelicSlotKey, +} from '@genshin-optimizer/sr-consts' +import { + allRelicSetKeys, + allRelicSlotKeys, + relicSlotToMainStatKeys, +} from '@genshin-optimizer/sr-consts' +import { cachedRelic } from '@genshin-optimizer/sr-db' +import type { IRelic, ISubstat } from '@genshin-optimizer/sr-srod' +import { getRelicMainStatDisplayVal } from '@genshin-optimizer/sr-util' +import { CardThemed, DropdownButton } from '@genshin-optimizer/ui-common' +import { clamp, deepClone } from '@genshin-optimizer/util' +import { Add } from '@mui/icons-material' +import LockIcon from '@mui/icons-material/Lock' +import LockOpenIcon from '@mui/icons-material/LockOpen' +import { + Alert, + Box, + Button, + ButtonGroup, + CardContent, + CardHeader, + Container, + Grid, + MenuItem, + Select, + Skeleton, + TextField, + Typography, +} from '@mui/material' +import { Suspense, useCallback, useEffect, useMemo, useReducer } from 'react' +import { useTranslation } from 'react-i18next' +import { LocationAutocomplete } from '../Character' +import { useDatabaseContext } from '../Context' +import RelicRarityDropdown from './RelicRarityDropdown' +import SubstatInput from './SubstatInput' +import { relicReducer } from './reducer' + +// TODO: temporary until relic sheet is implemented +interface IRelicSheet { + rarity: readonly RelicRarityKey[] + // setEffects: Dict + setEffects: any +} +const tempRelicSheet: IRelicSheet = { + rarity: [5, 4, 3, 2], + setEffects: {}, +} + +// TODO: relic sheets, errors, autocomplete, display text, i18n, ... +export type RelicEditorProps = { + relicIdToEdit?: string +} +export function RelicEditor({ relicIdToEdit = 'new' }: RelicEditorProps) { + const { t } = useTranslation('relic') + const { database } = useDatabaseContext() + const [dirtyDatabase, setDirtyDatabase] = useForceUpdate() + useEffect( + () => database.relics.followAny(setDirtyDatabase), + [database, setDirtyDatabase] + ) + + useEffect(() => { + if (relicIdToEdit === 'new') { + dispatchRelic({ type: 'reset' }) + } + const dbRelic = + relicIdToEdit && dirtyDatabase && database.relics.get(relicIdToEdit) + if (dbRelic) { + dispatchRelic({ + type: 'overwrite', + relic: deepClone(dbRelic), + }) + } + }, [relicIdToEdit, database, dirtyDatabase]) + + const [relic, dispatchRelic] = useReducer(relicReducer, undefined) + const { relic: cRelic, errors } = useMemo(() => { + if (!relic) return { relic: undefined, errors: [] } + const validated = cachedRelic(relic, relicIdToEdit) + return validated + }, [relic, relicIdToEdit]) + const { rarity = 5, level = 0, slotKey = 'head' } = relic ?? {} + const sheet: IRelicSheet | undefined = relic ? tempRelicSheet : undefined + + const update = useCallback( + (newValue: Partial) => { + // const newSheet = newValue.setKey ? getArtSheet(newValue.setKey) : sheet! + const newSheet = newValue.setKey ? tempRelicSheet : sheet! + + function pick( + value: T | undefined, + available: readonly T[], + prefer?: T + ): T { + return value && available.includes(value) + ? value + : prefer ?? available[0] + } + + if (newValue.setKey) { + newValue.rarity = pick( + relic?.rarity, + newSheet.rarity, + Math.max(...newSheet.rarity) as RelicRarityKey + ) + newValue.slotKey = pick(relic?.slotKey, ['hand', 'head']) + } + if (newValue.rarity) newValue.level = relic?.level ?? 0 + if (newValue.level) + newValue.level = clamp( + newValue.level, + 0, + 3 * (newValue.rarity ?? relic!.rarity) + ) + if (newValue.slotKey) + newValue.mainStatKey = pick( + relic?.mainStatKey, + relicSlotToMainStatKeys[newValue.slotKey] + ) + + if (newValue.mainStatKey) { + newValue.substats = [0, 1, 2, 3].map((i) => + relic && relic.substats[i].key !== newValue.mainStatKey + ? relic!.substats[i] + : { key: '', value: 0 } + ) + } + dispatchRelic({ type: 'update', relic: newValue }) + }, + [relic, sheet, dispatchRelic] + ) + const reset = useCallback(() => { + dispatchRelic({ type: 'reset' }) + }, []) + const setSubstat = useCallback( + (index: number, substat: ISubstat) => + dispatchRelic({ type: 'substat', index, substat }), + [] + ) + const isValid = !errors.length + + useEffect(() => { + if (relicIdToEdit === 'new') { + dispatchRelic({ type: 'reset' }) + } + }, [relicIdToEdit]) + + return ( + + + + + + {/* left column */} + + {/* set */} + + + update({ rarity })} + filter={(r) => !!sheet?.rarity?.includes?.(r)} + disabled={!sheet} + /> + + + {/* level */} + + { + const value = parseInt(e.target.value) || 0 + update({ level: value }) + }} + /> + + + {rarity + ? [...Array(rarity + 1).keys()] + .map((i) => 3 * i) + .map((i) => ( + + )) + : null} + + + + + {/* slot */} + + + + }> + {slotKey} + + + + + {/* main stat */} + + {relic?.mainStatKey ?? 'Main Stat'}} + disabled={!sheet} + color={relic ? 'success' : 'primary'} + > + {relicSlotToMainStatKeys[slotKey].map((mainStatK) => ( + update({ mainStatKey: mainStatK })} + > + {mainStatK} + + ))} + + + + {relic + ? getRelicMainStatDisplayVal( + rarity, + relic.mainStatKey, + level + ) + : t`mainStat`} + + + + + update({ location: charKey })} + /> + + + {/* right column */} + + {/* substat selections */} + {[0, 1, 2, 3].map((index) => ( + + ))} + + + + {/* Error alert */} + {!isValid && ( + + {errors.map((e, i) => ( +
{e}
+ ))} +
+ )} + + + +
+
+
+ ) +} diff --git a/libs/sr-ui/src/RelicEditor/RelicRarityDropdown.tsx b/libs/sr-ui/src/RelicEditor/RelicRarityDropdown.tsx new file mode 100644 index 0000000000..24269cf4a2 --- /dev/null +++ b/libs/sr-ui/src/RelicEditor/RelicRarityDropdown.tsx @@ -0,0 +1,40 @@ +import type { ButtonProps } from '@mui/material' +import { MenuItem } from '@mui/material' +import { useTranslation } from 'react-i18next' +import { DropdownButton, StarsDisplay } from '@genshin-optimizer/ui-common' +import { + type RelicRarityKey, + allRelicRarityKeys, +} from '@genshin-optimizer/sr-consts' + +type props = ButtonProps & { + rarity?: RelicRarityKey + onRarityChange: (rarity: RelicRarityKey) => void + filter: (rarity: RelicRarityKey) => boolean +} + +export default function RelicRarityDropdown({ + rarity, + onRarityChange, + filter, + ...props +}: props) { + const { t } = useTranslation('relic') + return ( + : t`editor.rarity`} + color={rarity ? 'success' : 'primary'} + > + {allRelicRarityKeys.map((rarity) => ( + onRarityChange(rarity)} + > + + + ))} + + ) +} diff --git a/libs/sr-ui/src/RelicEditor/SubstatInput.tsx b/libs/sr-ui/src/RelicEditor/SubstatInput.tsx new file mode 100644 index 0000000000..bb87da70ad --- /dev/null +++ b/libs/sr-ui/src/RelicEditor/SubstatInput.tsx @@ -0,0 +1,282 @@ +import { clamp, toPercent, unit } from '@genshin-optimizer/util' +import { + Box, + ButtonGroup, + Grid, + ListItemIcon, + ListItemText, + MenuItem, + Slider, + Typography, +} from '@mui/material' +import { useEffect, useMemo, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import type { ICachedRelic } from '@genshin-optimizer/sr-db' +import type { ISubstat } from '@genshin-optimizer/sr-srod' +import type { RelicSubStatKey } from '@genshin-optimizer/sr-consts' +import { + allRelicSubStatKeys, + relicSubstatRollData, +} from '@genshin-optimizer/sr-consts' +import { + getSubstatSummedRolls, + getSubstatValuesPercent, + roundStat, +} from '@genshin-optimizer/sr-util' +import { + CardThemed, + DropdownButton, + SqBadge, + TextButton, + CustomNumberInput, + CustomNumberInputButtonGroupWrapper, +} from '@genshin-optimizer/ui-common' + +// TODO: validation, roll table, roll values, efficiency, display text, icons, ... +export default function SubstatInput({ + index, + relic, + setSubstat, +}: { + index: number + relic: ICachedRelic | undefined + setSubstat: (index: number, substat: ISubstat) => void +}) { + const { t } = useTranslation('relic') + const { mainStatKey = '', rarity = 5 } = relic ?? {} + const { + key = '', + value = 0, + rolls = [], + efficiency = 0, + } = relic?.substats[index] ?? {} + // const accurateValue = rolls.reduce((a, b) => a + b, 0) + const rollNum = rolls.length + + let error = '', + rollData: readonly number[] = [], + allowedRolls = 0 + + if (relic) { + // Account for the rolls it will need to fill all 4 substates, +1 for its base roll + const rarity = relic.rarity + const { numUpgrades, high } = relicSubstatRollData[rarity] + const maxRollNum = numUpgrades + high - 3 + allowedRolls = maxRollNum - rollNum + rollData = key ? getSubstatValuesPercent(key, rarity) : [] + } + const rollOffset = 7 - rollData.length + + // if (!rollNum && key && value) error = error || t`editor.substat.error.noCalc` + if (allowedRolls < 0) + error = + error || + t('editor.substat.error.noOverRoll', { value: allowedRolls + rollNum }) + + const marks = useMemo( + () => + key + ? [ + { value: 0 }, + ...getSubstatSummedRolls(rarity, key).map((v) => ({ value: v })), + ] + : [{ value: 0 }], + [key, rarity] + ) + + return ( + + + + : undefined} + title={ + key ? ( + // +

{key}

+ ) : ( + t('editor.substat.substatFormat', { value: index + 1 }) + ) + } + disabled={!relic} + color={key ? 'success' : 'primary'} + sx={{ whiteSpace: 'nowrap' }} + > + {key && ( + setSubstat(index, { key: '', value: 0 })} + >{t`editor.substat.noSubstat`} + )} + {allRelicSubStatKeys + .filter((key) => mainStatKey !== key) + .map((k) => ( + setSubstat(index, { key: k, value: 0 })} + > + {/* */} + + {/* */} + {k} + + + ))} +
+ + { + let value = (v as number) ?? 0 + if (unit(key) === '%') { + value = value / 100 + } + setSubstat(index, { key, value }) + }} + disabled={!key} + error={!!error} + sx={{ + px: 1, + }} + inputProps={{ + sx: { textAlign: 'right' }, + }} + /> + + {!!rollData.length && ( + {t`editor.substat.nextRolls`} + )} + {/* {rollData.map((v, i) => { + let newValue = artDisplayValue(accurateValue + v, unit) + newValue = + allStats.art.subRollCorrection[rarity]?.[key]?.[newValue] ?? + newValue + return ( + + ) + })} */} +
+
+ + { + let value = (v as number) ?? 0 + if (unit(key) === '%') { + value = value / 100 + } + setSubstat(index, { key, value }) + }} + disabled={!key} + /> + + + {error ? ( + {t`ui:error`} + ) : ( + + {/* + + {rollNum + ? t('editor.substat.RollCount', { count: rollNum }) + : t`editor.substat.noRoll`} + + */} + + {!!rolls.length && + [...rolls].sort().map((val, i) => ( + + {/* {artDisplayValue(val, unit)} */} + {val} + + ))} + + + + + {'Efficiency: '} + {/* */} + {efficiency} + + + + + )} + +
+ ) +} +function SliderWrapper({ + value, + setValue, + marks, + disabled = false, +}: { + value: number + setValue: (v: number) => void + marks: Array<{ value: number }> + disabled: boolean +}) { + const [innerValue, setinnerValue] = useState(value) + useEffect(() => setinnerValue(value), [value]) + return ( + setinnerValue(v as number)} + onChangeCommitted={(_e, v) => setValue(v as number)} + valueLabelDisplay="auto" + /> + ) +} diff --git a/libs/sr-ui/src/RelicEditor/index.tsx b/libs/sr-ui/src/RelicEditor/index.tsx new file mode 100644 index 0000000000..53fa20f5a7 --- /dev/null +++ b/libs/sr-ui/src/RelicEditor/index.tsx @@ -0,0 +1 @@ +export * from './RelicEditor' diff --git a/libs/sr-ui/src/RelicEditor/reducer.ts b/libs/sr-ui/src/RelicEditor/reducer.ts new file mode 100644 index 0000000000..845edc40a2 --- /dev/null +++ b/libs/sr-ui/src/RelicEditor/reducer.ts @@ -0,0 +1,44 @@ +import type { IRelic, ISubstat } from '@genshin-optimizer/sr-srod' +import { validateRelic } from '@genshin-optimizer/sr-db' + +type ResetMessage = { type: 'reset' } +type SubstatMessage = { type: 'substat'; index: number; substat: ISubstat } +type OverwriteMessage = { type: 'overwrite'; relic: IRelic } +type UpdateMessage = { type: 'update'; relic: Partial } +type Message = ResetMessage | SubstatMessage | OverwriteMessage | UpdateMessage +export function relicReducer( + state: IRelic | undefined, + action: Message +): IRelic | undefined { + const handle = () => { + switch (action.type) { + case 'reset': + return undefined + case 'substat': { + const { index, substat } = action + const newSubstats = [...state!.substats] + const oldIndex = substat.key + ? newSubstats.findIndex((current) => current.key === substat.key) + : -1 + + if (oldIndex === -1 || oldIndex === index) { + newSubstats[index] = { ...substat } + } else { + // Already in used, swap the items instead + const temp = newSubstats[index] + newSubstats[index] = { ...newSubstats[oldIndex] } + newSubstats[oldIndex] = { ...temp } + } + + return { ...state!, substats: newSubstats } + } + case 'overwrite': + return action.relic + case 'update': + return { ...state!, ...action.relic } + } + } + const rel = handle() + if (!rel) return rel + return validateRelic(rel, true) +} diff --git a/libs/sr-ui/src/index.tsx b/libs/sr-ui/src/index.tsx index 9f655648fa..53175168db 100644 --- a/libs/sr-ui/src/index.tsx +++ b/libs/sr-ui/src/index.tsx @@ -1,3 +1,4 @@ export * from './Character' export * from './Context' export * from './Hook' +export * from './RelicEditor' diff --git a/libs/sr-util/src/relic.ts b/libs/sr-util/src/relic.ts index 8caab1eaec..690c5d73b7 100644 --- a/libs/sr-util/src/relic.ts +++ b/libs/sr-util/src/relic.ts @@ -134,8 +134,31 @@ export function randomizeRelic(base: Partial = {}): IRelic { } } -function roundStat(value: number, statKey: RelicMainStatKey | RelicSubStatKey) { +export function roundStat( + value: number, + statKey: RelicMainStatKey | RelicSubStatKey +) { return unit(statKey) === '%' ? Math.round(value * 10000) / 10000 : Math.round(value * 100) / 100 } + +// TODO: implement when roll table is added +export function getSubstatSummedRolls( + rarity: RelicRarityKey, + key: RelicSubStatKey +): number[] { + // for now, return min and max range + return Object.values(getSubstatRange(rarity, key, false)).map((v) => + toPercent(v, key) + ) +} + +// TODO: implement when roll table is added +export function getSubstatValuesPercent( + substatKey: RelicSubStatKey, + rarity: RelicRarityKey +) { + console.log('getSubstatValuesPercent', substatKey, rarity) + return [] +} diff --git a/libs/ui-common/src/components/CustomNumberInput.tsx b/libs/ui-common/src/components/CustomNumberInput.tsx new file mode 100644 index 0000000000..b5d8ff468d --- /dev/null +++ b/libs/ui-common/src/components/CustomNumberInput.tsx @@ -0,0 +1,110 @@ +import type { ButtonProps, InputProps } from '@mui/material' +import { Button, InputBase, styled } from '@mui/material' +import type { ChangeEvent, KeyboardEvent } from 'react' +import { useCallback, useEffect, useState } from 'react' +export type CustomNumberInputProps = Omit & { + value?: number | undefined + onChange: (newValue: number | undefined) => void + disabled?: boolean + float?: boolean + allowEmpty?: boolean + disableNegative?: boolean +} + +export const StyledInputBase = styled(InputBase)( + ({ theme, color = 'primary' }) => ({ + backgroundColor: theme.palette[color].main, + transition: 'all 0.5s ease', + '&:hover': { + backgroundColor: theme.palette[color].dark, + }, + '&.Mui-focused': { + backgroundColor: theme.palette[color].dark, + }, + '&.Mui-disabled': { + backgroundColor: theme.palette[color].dark, + }, + }) +) + +const Wrapper = styled(Button)(({ theme }) => ({ + backgroundColor: theme.palette.primary.main, + padding: 0, + overflow: 'hidden', + div: { + width: '100%', + height: '100%', + }, +})) + +// wrap the Input with this when using the input in a buttongroup +export function CustomNumberInputButtonGroupWrapper({ + children, + disableRipple, + disableFocusRipple, + disableTouchRipple, + ...props +}: ButtonProps) { + return ( + + {children} + + ) +} + +export function CustomNumberInput({ + value = 0, + onChange, + disabled = false, + float = false, + ...props +}: CustomNumberInputProps) { + const { inputProps = {}, ...restProps } = props + + const [number, setNumber] = useState(value) + const [focused, setFocus] = useState(false) + const parseFunc = useCallback( + (val: string) => (float ? parseFloat(val) : parseInt(val)), + [float] + ) + const onBlur = useCallback(() => { + onChange(number) + setFocus(false) + }, [onChange, number]) + const onFocus = useCallback(() => { + setFocus(true) + }, []) + useEffect(() => setNumber(value), [value]) // update value on value change + + const min = inputProps['min'] + const max = inputProps['max'] + const onInputChange = useCallback( + (e: ChangeEvent) => { + const newNum = parseFunc(e.target?.value) || 0 + if (min !== undefined && newNum < min) return + if (max !== undefined && newNum > max) return + setNumber(newNum) + }, + [parseFunc, min, max] + // [setNumber, parseFunc, inputProps['min'], inputProps['max']] + ) + const onKeyDown = useCallback( + (e: KeyboardEvent) => e.key === 'Enter' && onBlur(), + [onBlur] + ) + + return ( + + ) +} diff --git a/libs/ui-common/src/components/index.ts b/libs/ui-common/src/components/index.ts index ddc29eae5a..82594a3c8d 100644 --- a/libs/ui-common/src/components/index.ts +++ b/libs/ui-common/src/components/index.ts @@ -2,11 +2,12 @@ export * from './Card' export * from './BootstrapTooltip' export * from './ColorText' -export * from './SqBadge' export * from './ConditionalWrapper' -export * from './InfoTooltip' -export * from './StarDisplay' +export * from './CustomNumberInput' +export * from './DropdownButton' export * from './GeneralAutocomplete' +export * from './InfoTooltip' export * from './ModalWrapper' -export * from './DropdownButton' +export * from './SqBadge' +export * from './StarDisplay' export * from './TextButton'