Merge pull request #157 from jedmund/transcendence

Add support for Transcendence
This commit is contained in:
Justin Edmund 2023-01-22 21:35:10 -08:00 committed by GitHub
commit 7466d485df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 1083 additions and 55 deletions

View file

@ -57,11 +57,16 @@ const CharacterGrid = (props: Props) => {
})
const [errorMessage, setErrorMessage] = useState('')
// Create a temporary state to store previous character uncap values
// Create a temporary state to store previous weapon uncap values and transcendence stages
const [previousUncapValues, setPreviousUncapValues] = useState<{
[key: number]: number | undefined
}>({})
const [previousTranscendenceStages, setPreviousTranscendenceStages] =
useState<{
[key: number]: number | undefined
}>({})
// Set the editable flag only on first load
useEffect(() => {
// If user is logged in and matches
@ -269,6 +274,7 @@ const CharacterGrid = (props: Props) => {
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
storePreviousTranscendenceStage(position)
try {
if (uncapLevel != previousUncapValues[position])
@ -280,11 +286,17 @@ const CharacterGrid = (props: Props) => {
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
updateTranscendenceStage(position, previousTranscendenceStages[position])
// Remove optimistic key
let newPreviousValues = { ...previousUncapValues }
delete newPreviousValues[position]
setPreviousUncapValues(newPreviousValues)
let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
let newPreviousUncapValues = { ...previousUncapValues }
delete newPreviousTranscendenceStages[position]
delete newPreviousUncapValues[position]
setPreviousTranscendenceStages(newPreviousTranscendenceStages)
setPreviousUncapValues(newPreviousUncapValues)
}
}
@ -298,21 +310,25 @@ const CharacterGrid = (props: Props) => {
accountState.account.user &&
party.user.id === accountState.account.user.id
) {
memoizeAction(id, position, uncapLevel)
memoizeUncapAction(id, position, uncapLevel)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
if (uncapLevel < 6) {
updateTranscendenceStage(position, 0)
}
}
}
const memoizeAction = useCallback(
const memoizeUncapAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
debouncedUncapAction(id, position, uncapLevel)
},
[props, previousUncapValues]
)
const debouncedAction = useMemo(
const debouncedUncapAction = useMemo(
() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
@ -341,6 +357,106 @@ const CharacterGrid = (props: Props) => {
}
}
// Methods: Updating transcendence stage
// Note: Saves, but debouncing is not working properly
async function saveTranscendence(
id: string,
position: number,
stage: number
) {
storePreviousUncapValue(position)
storePreviousTranscendenceStage(position)
const payload = {
character: {
uncap_level: stage > 0 ? 6 : 5,
transcendence_step: stage,
},
}
try {
if (stage != previousTranscendenceStages[position])
await api.endpoints.grid_characters
.update(id, payload)
.then((response) => {
storeGridCharacter(response.data)
})
} catch (error) {
console.error(error)
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
updateTranscendenceStage(position, previousTranscendenceStages[position])
// Remove optimistic key
let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
let newPreviousUncapValues = { ...previousUncapValues }
delete newPreviousTranscendenceStages[position]
delete newPreviousUncapValues[position]
setPreviousTranscendenceStages(newPreviousTranscendenceStages)
setPreviousUncapValues(newPreviousUncapValues)
}
}
function initiateTranscendenceUpdate(
id: string,
position: number,
stage: number
) {
if (
party.user &&
accountState.account.user &&
party.user.id === accountState.account.user.id
) {
memoizeTranscendenceAction(id, position, stage)
// Optimistically update UI
updateTranscendenceStage(position, stage)
if (stage > 0) {
updateUncapLevel(position, 6)
}
}
}
const memoizeTranscendenceAction = useCallback(
(id: string, position: number, stage: number) => {
debouncedTranscendenceAction(id, position, stage)
},
[props, previousTranscendenceStages]
)
const debouncedTranscendenceAction = useMemo(
() =>
debounce((id, position, number) => {
saveTranscendence(id, position, number)
}, 500),
[props, saveTranscendence]
)
const updateTranscendenceStage = (
position: number,
stage: number | undefined
) => {
const character = appState.grid.characters[position]
if (character && stage !== undefined) {
character.transcendence_step = stage
appState.grid.characters[position] = character
}
}
function storePreviousTranscendenceStage(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = { ...previousUncapValues }
if (grid.characters[position]) {
newPreviousValues[position] = grid.characters[position]?.uncap_level
setPreviousTranscendenceStages(newPreviousValues)
}
}
function cancelAlert() {
setErrorMessage('')
}
@ -380,6 +496,7 @@ const CharacterGrid = (props: Props) => {
position={i}
updateObject={receiveCharacterFromSearch}
updateUncap={initiateUncapUpdate}
updateTranscendence={initiateTranscendenceUpdate}
removeCharacter={removeCharacter}
/>
</li>

View file

@ -40,6 +40,7 @@ interface Props {
removeCharacter: (id: string) => void
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
updateTranscendence: (id: string, position: number, stage: number) => void
}
const CharacterUnit = ({
@ -49,6 +50,7 @@ const CharacterUnit = ({
removeCharacter: sendCharacterToRemove,
updateObject,
updateUncap,
updateTranscendence,
}: Props) => {
// Translations and locale
const { t } = useTranslation('common')
@ -156,6 +158,10 @@ const CharacterUnit = ({
if (gridCharacter) updateUncap(gridCharacter.id, position, uncap)
}
function passTranscendenceData(stage: number) {
if (gridCharacter) updateTranscendence(gridCharacter.id, position, stage)
}
function removeCharacter() {
if (gridCharacter) sendCharacterToRemove(gridCharacter.id)
}
@ -169,8 +175,8 @@ const CharacterUnit = ({
// Change the image based on the uncap level
let suffix = '01'
if (gridCharacter.uncap_level == 6) suffix = '04'
else if (gridCharacter.uncap_level == 5) suffix = '03'
if (gridCharacter.transcendence_step > 0) suffix = '04'
else if (gridCharacter.uncap_level >= 5) suffix = '03'
else if (gridCharacter.uncap_level > 2) suffix = '02'
// Special casing for Lyria (and Young Cat eventually)
@ -280,7 +286,11 @@ const CharacterUnit = ({
}
const image = (
<div className="CharacterImage" onClick={openSearchModal}>
<div
className="CharacterImage"
onClick={openSearchModal}
tabIndex={gridCharacter ? gridCharacter.position * 7 : 0}
>
<img
alt={character?.name[locale]}
className="grid_image"
@ -308,7 +318,11 @@ const CharacterUnit = ({
flb={character.uncap.flb || false}
ulb={character.uncap.ulb || false}
uncapLevel={gridCharacter.uncap_level}
transcendenceStage={gridCharacter.transcendence_step}
position={gridCharacter.position}
editable={editable}
updateUncap={passUncapData}
updateTranscendence={passTranscendenceData}
special={character.special}
/>
) : (

View file

@ -14,6 +14,7 @@ interface Props {
removeSummon: (id: string) => void
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
updateTranscendence: (id: string, position: number, stage: number) => void
}
const ExtraSummons = (props: Props) => {
@ -36,6 +37,7 @@ const ExtraSummons = (props: Props) => {
gridSummon={props.grid[props.offset + i]}
updateObject={props.updateObject}
updateUncap={props.updateUncap}
updateTranscendence={props.updateTranscendence}
/>
</li>
)

View file

@ -0,0 +1,9 @@
.Popover {
animation: scaleIn $duration-zoom ease-out;
background: var(--dialog-bg);
border-radius: $card-corner;
border: 0.5px solid rgba(0, 0, 0, 0.18);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.24);
transform-origin: var(--radix-popover-content-transform-origin);
outline: none;
}

View file

@ -0,0 +1,37 @@
import React, { PropsWithChildren } from 'react'
import classnames from 'classnames'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import './index.scss'
interface Props
extends React.DetailedHTMLProps<
React.DialogHTMLAttributes<HTMLDivElement>,
HTMLDivElement
> {}
export const Popover = PopoverPrimitive.Root
export const PopoverAnchor = PopoverPrimitive.Anchor
export const PopoverTrigger = PopoverPrimitive.Trigger
export const PopoverContent = React.forwardRef<HTMLDivElement, Props>(
({ children, ...props }: PropsWithChildren<Props>, forwardedRef) => {
const classes = classnames(props.className, {
Popover: true,
})
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
sideOffset={5}
{...props}
className={classes}
ref={forwardedRef}
>
{children}
<PopoverPrimitive.Arrow />
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
)
}
)

View file

@ -42,10 +42,14 @@ const SummonGrid = (props: Props) => {
const { party, grid } = useSnapshot(appState)
const [slug, setSlug] = useState()
// Create a temporary state to store previous weapon uncap value
// Create a temporary state to store previous weapon uncap values and transcendence stages
const [previousUncapValues, setPreviousUncapValues] = useState<{
[key: number]: number
}>({})
const [previousTranscendenceStages, setPreviousTranscendenceStages] =
useState<{
[key: number]: number
}>({})
// Set the editable flag only on first load
useEffect(() => {
@ -155,6 +159,7 @@ const SummonGrid = (props: Props) => {
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
storePreviousTranscendenceStage(position)
try {
if (uncapLevel != previousUncapValues[position])
@ -166,11 +171,17 @@ const SummonGrid = (props: Props) => {
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
updateTranscendenceStage(position, previousTranscendenceStages[position])
// Remove optimistic key
let newPreviousValues = { ...previousUncapValues }
delete newPreviousValues[position]
setPreviousUncapValues(newPreviousValues)
let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
let newPreviousUncapValues = { ...previousUncapValues }
delete newPreviousTranscendenceStages[position]
delete newPreviousUncapValues[position]
setPreviousTranscendenceStages(newPreviousTranscendenceStages)
setPreviousUncapValues(newPreviousUncapValues)
}
}
@ -184,21 +195,25 @@ const SummonGrid = (props: Props) => {
accountState.account.user &&
party.user.id === accountState.account.user.id
) {
memoizeAction(id, position, uncapLevel)
memoizeUncapAction(id, position, uncapLevel)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
if (uncapLevel < 6) {
updateTranscendenceStage(position, 0)
}
}
}
const memoizeAction = useCallback(
const memoizeUncapAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
debouncedUncapAction(id, position, uncapLevel)
},
[props, previousUncapValues]
)
const debouncedAction = useMemo(
const debouncedUncapAction = useMemo(
() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
@ -237,6 +252,116 @@ const SummonGrid = (props: Props) => {
setPreviousUncapValues(newPreviousValues)
}
// Methods: Updating transcendence stage
// Note: Saves, but debouncing is not working properly
async function saveTranscendence(
id: string,
position: number,
stage: number
) {
storePreviousUncapValue(position)
storePreviousTranscendenceStage(position)
const payload = {
summon: {
uncap_level: stage > 0 ? 6 : 5,
transcendence_step: stage,
},
}
try {
if (stage != previousTranscendenceStages[position])
await api.endpoints.grid_summons
.update(id, payload)
.then((response) => {
storeGridSummon(response.data.grid_summon)
})
} catch (error) {
console.error(error)
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
updateTranscendenceStage(position, previousTranscendenceStages[position])
// Remove optimistic key
let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
let newPreviousUncapValues = { ...previousUncapValues }
delete newPreviousTranscendenceStages[position]
delete newPreviousUncapValues[position]
setPreviousTranscendenceStages(newPreviousTranscendenceStages)
setPreviousUncapValues(newPreviousUncapValues)
}
}
function initiateTranscendenceUpdate(
id: string,
position: number,
stage: number
) {
if (
party.user &&
accountState.account.user &&
party.user.id === accountState.account.user.id
) {
memoizeTranscendenceAction(id, position, stage)
// Optimistically update UI
updateTranscendenceStage(position, stage)
if (stage > 0) {
updateUncapLevel(position, 6)
}
}
}
const memoizeTranscendenceAction = useCallback(
(id: string, position: number, stage: number) => {
debouncedTranscendenceAction(id, position, stage)
},
[props, previousTranscendenceStages]
)
const debouncedTranscendenceAction = useMemo(
() =>
debounce((id, position, number) => {
saveTranscendence(id, position, number)
}, 500),
[props, saveTranscendence]
)
const updateTranscendenceStage = (position: number, stage: number) => {
if (appState.grid.summons.mainSummon && position == -1)
appState.grid.summons.mainSummon.transcendence_step = stage
else if (appState.grid.summons.friendSummon && position == 6)
appState.grid.summons.friendSummon.transcendence_step = stage
else {
const summon = appState.grid.summons.allSummons[position]
if (summon) {
summon.transcendence_step = stage
appState.grid.summons.allSummons[position] = summon
}
}
}
function storePreviousTranscendenceStage(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = { ...previousUncapValues }
if (appState.grid.summons.mainSummon && position == -1)
newPreviousValues[position] = appState.grid.summons.mainSummon.uncap_level
else if (appState.grid.summons.friendSummon && position == 6)
newPreviousValues[position] =
appState.grid.summons.friendSummon.uncap_level
else {
const summon = appState.grid.summons.allSummons[position]
newPreviousValues[position] = summon ? summon.uncap_level : 0
}
setPreviousUncapValues(newPreviousValues)
}
async function removeSummon(id: string) {
try {
const response = await api.endpoints.grid_summons.destroy({ id: id })
@ -267,6 +392,7 @@ const SummonGrid = (props: Props) => {
removeSummon={removeSummon}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
updateTranscendence={initiateTranscendenceUpdate}
/>
</div>
)
@ -280,11 +406,14 @@ const SummonGrid = (props: Props) => {
key="grid_friend_summon"
position={6}
unitType={2}
removeSummon={removeSummon}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
updateTranscendence={initiateTranscendenceUpdate}
/>
</div>
)
const summonGridElement = (
<div id="LabeledGrid">
<div className="Label">{t('summons.summons')}</div>
@ -300,6 +429,7 @@ const SummonGrid = (props: Props) => {
removeSummon={removeSummon}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
updateTranscendence={initiateTranscendenceUpdate}
/>
</li>
)
@ -307,6 +437,7 @@ const SummonGrid = (props: Props) => {
</ul>
</div>
)
const subAuraSummonElement = (
<ExtraSummons
grid={grid.summons.allSummons}
@ -316,8 +447,10 @@ const SummonGrid = (props: Props) => {
removeSummon={removeSummon}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
updateTranscendence={initiateTranscendenceUpdate}
/>
)
return (
<div>
<div id="SummonGrid">

View file

@ -29,6 +29,7 @@ interface Props {
removeSummon: (id: string) => void
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
updateTranscendence: (id: string, position: number, stage: number) => void
}
const SummonUnit = ({
@ -39,6 +40,7 @@ const SummonUnit = ({
removeSummon: sendSummonToRemove,
updateObject,
updateUncap,
updateTranscendence,
}: Props) => {
// Translations and locale
const { t } = useTranslation('common')
@ -105,6 +107,10 @@ const SummonUnit = ({
if (gridSummon) updateUncap(gridSummon.id, position, uncap)
}
function passTranscendenceData(stage: number) {
if (gridSummon) updateTranscendence(gridSummon.id, position, stage)
}
function removeSummon() {
if (gridSummon) sendSummonToRemove(gridSummon.id)
}
@ -133,11 +139,14 @@ const SummonUnit = ({
]
let suffix = ''
if (
if (gridSummon.object.uncap.xlb && gridSummon.uncap_level == 6) {
suffix = '_03'
} else if (
upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 &&
gridSummon.uncap_level == 5
)
) {
suffix = '_02'
}
// Generate the correct source for the summon
if (unitType == 0 || unitType == 2)
@ -243,8 +252,13 @@ const SummonUnit = ({
type="summon"
ulb={gridSummon.object.uncap.ulb || false}
flb={gridSummon.object.uncap.flb || false}
xlb={gridSummon.object.uncap.xlb || false}
editable={editable}
uncapLevel={gridSummon.uncap_level}
transcendenceStage={gridSummon.transcendence_step}
position={gridSummon.position}
updateUncap={passUncapData}
updateTranscendence={passTranscendenceData}
special={false}
/>
) : (

View file

@ -0,0 +1,83 @@
.Fragment {
$degrees: 72deg;
$origWidth: 29px;
$origHeight: 54px;
$scaledWidth: 12px;
$scaledHeight: calc(($scaledWidth / $origWidth) * $origHeight);
$scale: 1.2;
@include hidpiImage(
'/icons/transcendence/interactive/interactive-piece',
png,
$scaledWidth,
$scaledHeight
);
position: absolute;
z-index: 32;
aspect-ratio: 29 / 54;
height: $scaledHeight;
width: $scaledWidth;
opacity: 0;
&:hover {
cursor: pointer;
}
&.Visible {
opacity: 1;
}
&.Stage1 {
top: 3px;
left: 18px;
// &:hover {
// transform: scale($scale, $scale) translateY(-2px);
// }
}
&.Stage2 {
top: 10px;
left: 27px;
transform: rotate($degrees);
// &:hover {
// transform: rotate($degrees) scale($scale, $scale) translateY(-2px);
// }
}
&.Stage3 {
top: 21px;
left: 24px;
transform: rotate($degrees * 2);
// &:hover {
// transform: rotate($degrees * 2) scale($scale, $scale) translateY(-1px);
// }
}
&.Stage4 {
top: 21px;
left: 12px;
transform: rotate($degrees * 3);
// &:hover {
// transform: rotate($degrees * 3) scale($scale, $scale) translateY(-1px);
// }
}
&.Stage5 {
top: 10px;
left: 8px;
transform: rotate($degrees * 4);
// &:hover {
// transform: rotate($degrees * 4) scale($scale, $scale) translateY(-1px);
// }
}
}

View file

@ -0,0 +1,49 @@
import React from 'react'
import classnames from 'classnames'
import './index.scss'
interface Props {
stage: number
interactive: boolean
visible: boolean
onClick?: (index: number) => void
onHover?: (index: number) => void
}
const TranscendenceFragment = ({
interactive,
stage,
visible,
onClick,
onHover,
}: Props) => {
const classes = classnames({
Fragment: true,
Visible: visible,
Stage1: stage === 1,
Stage2: stage === 2,
Stage3: stage === 3,
Stage4: stage === 4,
Stage5: stage === 5,
})
function handleClick() {
if (interactive && onClick) onClick(stage)
}
function handleHover() {
if (interactive && onHover) onHover(stage)
}
return (
<i className={classes} onClick={handleClick} onMouseOver={handleHover} />
)
}
TranscendenceFragment.defaultProps = {
interactive: false,
visible: false,
}
export default TranscendenceFragment

View file

@ -0,0 +1,25 @@
.Transcendence.Popover {
align-items: center;
flex-direction: column;
gap: $unit-half;
display: flex;
width: $unit-10x;
height: $unit-10x;
padding: $unit;
justify-content: center;
z-index: 32;
&.open {
opacity: 1;
display: flex;
}
h4 {
font-size: $font-small;
font-weight: $medium;
}
.Pending {
color: $yellow;
}
}

View file

@ -0,0 +1,87 @@
import React, { PropsWithChildren, useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import { Popover, PopoverAnchor, PopoverContent } from '~components/Popover'
import TranscendenceStar from '~components/TranscendenceStar'
import './index.scss'
interface Props
extends React.DetailedHTMLProps<
React.DialogHTMLAttributes<HTMLDivElement>,
HTMLDivElement
> {
open: boolean
stage: number
onOpenChange?: (open: boolean) => void
sendValue?: (stage: number) => void
}
const TranscendencePopover = ({
children,
open: popoverOpen,
stage,
tabIndex,
onOpenChange,
sendValue,
}: PropsWithChildren<Props>) => {
const { t } = useTranslation('common')
const [open, setOpen] = useState(false)
const [currentStage, setCurrentStage] = useState(0)
const popoverRef = React.createRef<HTMLDivElement>()
const classes = classNames({
Transcendence: true,
})
const levelClasses = classNames({
Pending: stage != currentStage,
})
useEffect(() => {
if (open) popoverRef.current?.focus()
}, [])
useEffect(() => {
setCurrentStage(stage)
}, [stage])
useEffect(() => {
setOpen(popoverOpen)
}, [popoverOpen])
function handleFragmentClicked(newStage: number) {
setCurrentStage(newStage)
if (sendValue) sendValue(newStage)
}
function handleFragmentHovered(newStage: number) {
setCurrentStage(newStage)
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverAnchor>{children}</PopoverAnchor>
<PopoverContent className={classes} ref={popoverRef} tabIndex={tabIndex}>
<TranscendenceStar
className="Interactive Base"
editable={true}
interactive={true}
stage={stage}
onFragmentClick={handleFragmentClicked}
onFragmentHover={handleFragmentHovered}
/>
<h4>
<span>{t('level')}&nbsp;</span>
<span className={levelClasses}>{200 + 10 * currentStage}</span>
</h4>
</PopoverContent>
</Popover>
)
}
export default TranscendencePopover

View file

@ -0,0 +1,108 @@
.TranscendenceStar {
$size: 18px;
position: relative;
&:hover {
transform: scale(1.2);
}
&.Immutable {
pointer-events: none;
}
&.Empty {
@include hidpiImage('/icons/transcendence/0/stage-0', png, $size, $size);
}
&.Stage1 {
@include hidpiImage('/icons/transcendence/1/stage-1', png, $size, $size);
}
&.Stage2 {
@include hidpiImage('/icons/transcendence/2/stage-2', png, $size, $size);
}
&.Stage3 {
@include hidpiImage('/icons/transcendence/3/stage-3', png, $size, $size);
}
&.Stage4 {
@include hidpiImage('/icons/transcendence/4/stage-4', png, $size, $size);
}
&.Stage5 {
@include hidpiImage('/icons/transcendence/5/stage-5', png, $size, $size);
}
.Figure {
$size: 18px;
background-repeat: no-repeat;
background-size: 54px 54px;
display: block;
height: $size;
width: $size;
&.Interactive.Base {
$size: $unit-6x;
@include hidpiImage(
'/icons/transcendence/interactive/interactive-base',
png,
$size,
$size
);
height: $size;
width: $size;
&:hover {
cursor: pointer;
transform: none;
}
}
&:hover {
transform: scale(1.2);
}
&.Stage1 {
background-image: url('/icons/transcendence/1/step-1@3x.png');
&:hover {
background-image: url('/icons/transcendence/1/step-1-hover@3x.png');
}
}
&.Stage2 {
background-image: url('/icons/transcendence/2/step-2@3x.png');
&:hover {
background-image: url('/icons/transcendence/2/step-2-hover@3x.png');
}
}
&.Stage3 {
background-image: url('/icons/transcendence/3/step-3@3x.png');
&:hover {
background-image: url('/icons/transcendence/3/step-3-hover@3x.png');
}
}
&.Stage4 {
background-image: url('/icons/transcendence/4/step-4@3x.png');
&:hover {
background-image: url('/icons/transcendence/4/step-4-hover@3x.png');
}
}
&.Stage5 {
background-image: url('/icons/transcendence/5/step-5@3x.png');
&:hover {
background-image: url('/icons/transcendence/5/step-5-hover@3x.png');
}
}
}
}

View file

@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react'
import classnames from 'classnames'
import TranscendenceFragment from '~components/TranscendenceFragment'
import './index.scss'
interface Props
extends React.DetailedHTMLProps<
React.DialogHTMLAttributes<HTMLDivElement>,
HTMLDivElement
> {
className?: string
stage: number
editable: boolean
interactive: boolean
onStarClick?: () => void
onFragmentClick?: (newStage: number) => void
onFragmentHover?: (newStage: number) => void
}
const NUM_FRAGMENTS = 5
const TranscendenceStar = ({
className,
interactive,
stage,
editable,
tabIndex,
onStarClick,
onFragmentClick,
onFragmentHover,
}: Props) => {
const [visibleStage, setVisibleStage] = useState(0)
const [currentStage, setCurrentStage] = useState(0)
const [immutable, setImmutable] = useState(false)
// Classes
const starClasses = classnames({
TranscendenceStar: true,
Immutable: immutable,
Empty: stage === 0,
Stage1: stage === 1,
Stage2: stage === 2,
Stage3: stage === 3,
Stage4: stage === 4,
Stage5: stage === 5,
})
const baseImageClasses = classnames(className, {
Figure: true,
})
useEffect(() => {
setVisibleStage(stage)
setCurrentStage(stage)
}, [stage])
function handleClick() {
if (onStarClick) {
onStarClick()
}
}
function handleFragmentClick(index: number) {
let newStage = index
if (index === currentStage) newStage = 0
setVisibleStage(newStage)
setCurrentStage(newStage)
if (onFragmentClick) onFragmentClick(newStage)
}
function handleFragmentHover(index: number) {
setVisibleStage(index)
if (onFragmentHover) onFragmentHover(index)
}
function handleMouseLeave() {
setVisibleStage(currentStage)
if (onFragmentHover) onFragmentHover(currentStage)
}
return (
<div
className={starClasses}
onClick={editable ? handleClick : () => {}}
onMouseLeave={interactive ? handleMouseLeave : () => {}}
tabIndex={tabIndex}
>
<div className="Fragments">
{[...Array(NUM_FRAGMENTS)].map((e, i) => {
const loopStage = i + 1
return interactive ? (
<TranscendenceFragment
key={`fragment_${loopStage}`}
stage={loopStage}
visible={loopStage <= visibleStage}
interactive={interactive}
onClick={handleFragmentClick}
onHover={handleFragmentHover}
/>
) : (
''
)
})}
</div>
<i className={baseImageClasses} />
</div>
)
}
TranscendenceStar.defaultProps = {
stage: 0,
editable: false,
interactive: false,
}
export default TranscendenceStar

View file

@ -1,3 +1,7 @@
.Uncap {
position: relative;
}
.UncapIndicator {
display: flex;
flex-direction: row;

View file

@ -1,5 +1,7 @@
import React from 'react'
import React, { useState } from 'react'
import UncapStar from '~components/UncapStar'
import TranscendencePopover from '~components/TranscendencePopover'
import TranscendenceStar from '~components/TranscendenceStar'
import './index.scss'
@ -7,14 +9,22 @@ interface Props {
type: 'character' | 'weapon' | 'summon'
rarity?: number
uncapLevel?: number
position: number
transcendenceStage?: number
editable: boolean
flb: boolean
ulb: boolean
xlb?: boolean
special: boolean
updateUncap?: (index: number) => void
updateTranscendence?: (index: number) => void
}
const UncapIndicator = (props: Props) => {
const numStars = setNumStars()
const [popoverOpen, setPopoverOpen] = useState(false)
function setNumStars() {
let numStars
@ -37,7 +47,9 @@ const UncapIndicator = (props: Props) => {
}
}
} else {
if (props.ulb) {
if (props.xlb) {
numStars = 6
} else if (props.ulb) {
numStars = 5
} else if (props.flb) {
numStars = 4
@ -56,14 +68,41 @@ const UncapIndicator = (props: Props) => {
}
}
function togglePopover(open: boolean) {
setPopoverOpen(open)
}
function sendTranscendenceStage(stage: number) {
if (props.updateTranscendence) props.updateTranscendence(stage)
togglePopover(false)
}
const transcendence = (i: number) => {
return (
<UncapStar
ulb={true}
empty={props.uncapLevel ? i >= props.uncapLevel : false}
const tabIndex = props.position * 7 + i + 1
return props.type === 'character' || props.type === 'summon' ? (
<TranscendencePopover
open={popoverOpen}
stage={props.transcendenceStage ? props.transcendenceStage : 0}
onOpenChange={togglePopover}
sendValue={sendTranscendenceStage}
key={`star_${i}`}
index={i}
onClick={toggleStar}
tabIndex={tabIndex}
>
<TranscendenceStar
key={`star_${i}`}
stage={props.transcendenceStage}
editable={props.editable}
interactive={false}
onStarClick={() => togglePopover(true)}
/>
</TranscendencePopover>
) : (
<TranscendenceStar
key={`star_${i}`}
stage={props.transcendenceStage}
editable={props.editable}
interactive={false}
tabIndex={tabIndex}
/>
)
}
@ -76,7 +115,8 @@ const UncapIndicator = (props: Props) => {
empty={props.uncapLevel != null ? i >= props.uncapLevel : false}
key={`star_${i}`}
index={i}
onClick={toggleStar}
onStarClick={toggleStar}
tabIndex={props.position * 7 + i + 1}
/>
)
}
@ -89,7 +129,8 @@ const UncapIndicator = (props: Props) => {
empty={props.uncapLevel != null ? i >= props.uncapLevel : false}
key={`star_${i}`}
index={i}
onClick={toggleStar}
onStarClick={toggleStar}
tabIndex={props.position * 7 + i + 1}
/>
)
}
@ -101,29 +142,38 @@ const UncapIndicator = (props: Props) => {
empty={props.uncapLevel != null ? i >= props.uncapLevel : false}
key={`star_${i}`}
index={i}
onClick={toggleStar}
onStarClick={toggleStar}
tabIndex={props.position * 7 + i + 1}
/>
)
}
return (
<ul className="UncapIndicator">
{Array.from(Array(numStars)).map((x, i) => {
if (props.type === 'character' && i > 4) {
if (props.special) return ulb(i)
else return transcendence(i)
} else if (
(props.special && props.type === 'character' && i == 3) ||
(props.type === 'character' && i == 4) ||
(props.type !== 'character' && i > 2)
) {
return flb(i)
} else {
return mlb(i)
}
})}
</ul>
<div className="Uncap">
<ul className="UncapIndicator">
{Array.from(Array(numStars)).map((x, i) => {
if (props.type === 'character' && i > 4) {
if (props.special) return ulb(i)
else return transcendence(i)
} else if (props.type === 'summon' && i > 4) {
return transcendence(i)
} else if (
(props.special && props.type === 'character' && i == 3) ||
(props.type === 'character' && i == 4) ||
(props.type !== 'character' && i > 2)
) {
return flb(i)
} else {
return mlb(i)
}
})}
</ul>
</div>
)
}
UncapIndicator.defaultProps = {
editable: false,
}
export default UncapIndicator

View file

@ -3,13 +3,17 @@ import classnames from 'classnames'
import './index.scss'
interface Props {
interface Props
extends React.DetailedHTMLProps<
React.DialogHTMLAttributes<HTMLDivElement>,
HTMLDivElement
> {
empty: boolean
special: boolean
flb: boolean
ulb: boolean
index: number
onClick: (index: number, empty: boolean) => void
onStarClick: (index: number, empty: boolean) => void
}
const UncapStar = (props: Props) => {
@ -23,10 +27,12 @@ const UncapStar = (props: Props) => {
})
function clicked() {
props.onClick(props.index, props.empty)
props.onStarClick(props.index, props.empty)
}
return <li className={classes} onClick={clicked}></li>
return (
<li className={classes} tabIndex={props.tabIndex} onClick={clicked}></li>
)
}
UncapStar.defaultProps = {

View file

@ -548,6 +548,7 @@ const WeaponUnit = ({
ulb={gridWeapon.object.uncap.ulb || false}
flb={gridWeapon.object.uncap.flb || false}
uncapLevel={gridWeapon.uncap_level}
position={gridWeapon.position}
updateUncap={passUncapData}
special={false}
/>

93
package-lock.json generated
View file

@ -10,6 +10,7 @@
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.1",
"@radix-ui/react-hover-card": "^1.0.2",
"@radix-ui/react-popover": "^1.0.3",
"@radix-ui/react-select": "^1.1.2",
"@radix-ui/react-switch": "^1.0.1",
"@radix-ui/react-toast": "^1.1.2",
@ -2325,6 +2326,55 @@
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.3.tgz",
"integrity": "sha512-YwedSukfWsyJs3/yP3yXUq44k4/JBe3jqU63Z8v2i19qZZ3dsx32oma17ztgclWPNuqp3A+Xa9UiDlZHyVX8Vg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-dismissable-layer": "1.0.2",
"@radix-ui/react-focus-guards": "1.0.0",
"@radix-ui/react-focus-scope": "1.0.1",
"@radix-ui/react-id": "1.0.0",
"@radix-ui/react-popper": "1.1.0",
"@radix-ui/react-portal": "1.0.1",
"@radix-ui/react-presence": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-slot": "1.0.1",
"@radix-ui/react-use-controllable-state": "1.0.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.5"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@floating-ui/react-dom": "0.7.2",
"@radix-ui/react-arrow": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-use-callback-ref": "1.0.0",
"@radix-ui/react-use-layout-effect": "1.0.0",
"@radix-ui/react-use-rect": "1.0.0",
"@radix-ui/react-use-size": "1.0.0",
"@radix-ui/rect": "1.0.0"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.0.1.tgz",
@ -8809,6 +8859,49 @@
"react-remove-scroll": "2.5.5"
}
},
"@radix-ui/react-popover": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.3.tgz",
"integrity": "sha512-YwedSukfWsyJs3/yP3yXUq44k4/JBe3jqU63Z8v2i19qZZ3dsx32oma17ztgclWPNuqp3A+Xa9UiDlZHyVX8Vg==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-dismissable-layer": "1.0.2",
"@radix-ui/react-focus-guards": "1.0.0",
"@radix-ui/react-focus-scope": "1.0.1",
"@radix-ui/react-id": "1.0.0",
"@radix-ui/react-popper": "1.1.0",
"@radix-ui/react-portal": "1.0.1",
"@radix-ui/react-presence": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-slot": "1.0.1",
"@radix-ui/react-use-controllable-state": "1.0.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.5"
},
"dependencies": {
"@radix-ui/react-popper": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
"requires": {
"@babel/runtime": "^7.13.10",
"@floating-ui/react-dom": "0.7.2",
"@radix-ui/react-arrow": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-use-callback-ref": "1.0.0",
"@radix-ui/react-use-layout-effect": "1.0.0",
"@radix-ui/react-use-rect": "1.0.0",
"@radix-ui/react-use-size": "1.0.0",
"@radix-ui/rect": "1.0.0"
}
}
}
},
"@radix-ui/react-popper": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.0.1.tgz",

View file

@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.1",
"@radix-ui/react-hover-card": "^1.0.2",
"@radix-ui/react-popover": "^1.0.3",
"@radix-ui/react-select": "^1.1.2",
"@radix-ui/react-switch": "^1.0.1",
"@radix-ui/react-toast": "^1.1.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View file

@ -370,5 +370,6 @@
"no_raid": "No raid",
"no_user": "Anonymous",
"no_job": "No class",
"no_value": "No value"
"no_value": "No value",
"level": "Level"
}

View file

@ -371,5 +371,6 @@
"no_raid": "マルチなし",
"no_user": "無名",
"no_job": "ジョブなし",
"no_value": "値なし"
"no_value": "値なし",
"level": "レベル"
}

View file

@ -321,3 +321,38 @@ i.tag {
backdrop-filter: blur(5px) saturate(100%) brightness(80%) opacity(1);
}
}
@keyframes scaleIn {
0% {
opacity: 0;
transform: scale(0);
}
20% {
opacity: 0.2;
transform: scale(0.4);
}
40% {
opacity: 0.4;
transform: scale(0.8);
}
60% {
opacity: 0.6;
transform: scale(1);
}
70% {
opacity: 0.8;
transform: scale(1.1);
}
80% {
opacity: 0.8;
transform: scale(1);
}
90% {
opacity: 0.8;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View file

@ -26,3 +26,36 @@
}
}
}
@mixin hidpiImage(
$image,
$extension,
$width,
$height,
$position: center,
$repeat: no-repeat
) {
background: url($image + '.' + $extension) $repeat $position;
@media screen and (-webkit-min-device-pixel-ratio: 2),
screen and (min--moz-device-pixel-ratio: 2),
screen and (-moz-min-device-pixel-ratio: 2),
screen and (-o-min-device-pixel-ratio: 2/1),
screen and (min-device-pixel-ratio: 2),
screen and (min-resolution: 192dpi),
screen and (min-resolution: 2dppx) {
background: url($image + '@2x' + '.' + $extension) $repeat $position;
background-size: $width $height;
}
@media screen and (-webkit-min-device-pixel-ratio: 3),
screen and (min--moz-device-pixel-ratio: 3),
screen and (-moz-min-device-pixel-ratio: 3),
screen and (-o-min-device-pixel-ratio: 3/1),
screen and (min-device-pixel-ratio: 3),
screen and (min-resolution: 216dpi),
screen and (min-resolution: 3dppx) {
background: url($image + '@3x' + '.' + $extension) $repeat $position;
background-size: $width $height;
}
}

View file

@ -26,6 +26,7 @@ $unit-half: calc($unit / 2);
$unit-2x: $unit * 2;
$unit-3x: $unit * 3;
$unit-4x: $unit * 4;
$unit-5x: $unit * 5;
$unit-6x: $unit * 6;
$unit-8x: $unit * 8;
$unit-10x: $unit * 10;

View file

@ -3,6 +3,7 @@ interface GridCharacter {
position: number
object: Character
uncap_level: number
transcendence_step: number
over_mastery: CharacterOverMastery
aetherial_mastery: ExtendedMastery
awakening: {

View file

@ -5,4 +5,5 @@ interface GridSummon {
position: number
object: Summon
uncap_level: number
transcendence_step: number
}

3
types/Summon.d.ts vendored
View file

@ -15,16 +15,19 @@ interface Summon {
max_hp: number
max_hp_flb: number
max_hp_ulb: number
max_hp_xlb: number
}
atk: {
min_atk: number
max_atk: number
max_atk_flb: number
max_atk_ulb: number
max_atk_xlb: number
}
uncap: {
flb: boolean
ulb: boolean
xlb: boolean
}
position?: number
}

View file

@ -138,7 +138,8 @@ class Api {
return axios.post(resourceUrl, {
[resource]: {
id: id,
uncap_level: value
uncap_level: value,
transcendence_step: 0
}
})
}