1645 lines
53 KiB
TypeScript
1645 lines
53 KiB
TypeScript
import {
|
|
beginHeavyAnimation,
|
|
memo, useEffect, useMemo, useRef, useState,
|
|
} from '../../../../lib/teact/teact';
|
|
import { getActions, withGlobal } from '../../../../global';
|
|
|
|
import type {
|
|
ApiStarGiftAttributeBackdrop,
|
|
ApiStarGiftAttributeModel,
|
|
ApiStarGiftAttributePattern,
|
|
ApiStarGiftUnique,
|
|
ApiSticker,
|
|
} from '../../../../api/types';
|
|
import type { TabState } from '../../../../global/types';
|
|
|
|
import { requestMeasure, requestMutation } from '../../../../lib/fasterdom/fasterdom';
|
|
import { VTT_CRAFT_ATTRIBUTES } from '../../../../util/animations/viewTransitionTypes';
|
|
import buildClassName from '../../../../util/buildClassName';
|
|
import { getNextArrowReplacement } from '../../../../util/localization/format';
|
|
import { formatPercent } from '../../../../util/textFormat';
|
|
import { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets';
|
|
import { getGiftAttributes } from '../../../common/helpers/gifts';
|
|
import { REM } from '../../../common/helpers/mediaDimensions';
|
|
|
|
import { useViewTransition } from '../../../../hooks/animations/useViewTransition';
|
|
import { useVtn } from '../../../../hooks/animations/useVtn';
|
|
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
|
|
import useFlag from '../../../../hooks/useFlag';
|
|
import useLang from '../../../../hooks/useLang';
|
|
import useLastCallback from '../../../../hooks/useLastCallback';
|
|
|
|
import AnimatedCounter from '../../../common/AnimatedCounter';
|
|
import AnimatedIcon from '../../../common/AnimatedIcon';
|
|
import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker';
|
|
import AnimatedIconWithPreview from '../../../common/AnimatedIconWithPreview';
|
|
import CustomEmoji from '../../../common/CustomEmoji';
|
|
import GiftRibbon from '../../../common/gift/GiftRibbon';
|
|
import Icon from '../../../common/icons/Icon';
|
|
import RadialPatternBackground from '../../../common/profile/RadialPatternBackground';
|
|
import StickerView from '../../../common/StickerView';
|
|
import Button from '../../../ui/Button';
|
|
import Modal from '../../../ui/Modal';
|
|
import Transition from '../../../ui/Transition';
|
|
import RadialProgress from './RadialProgress';
|
|
|
|
import styles from './GiftCraftModal.module.scss';
|
|
|
|
import craftPatternUrl from '../../../../assets/font-icons/craft.svg';
|
|
|
|
export type OwnProps = {
|
|
modal: TabState['giftCraftModal'];
|
|
};
|
|
|
|
type StateProps = {
|
|
craftAttributePermilles?: number[];
|
|
};
|
|
|
|
// ===================
|
|
// Types
|
|
// ===================
|
|
type FaceName = 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom';
|
|
|
|
interface Vector3D {
|
|
x: number;
|
|
y: number;
|
|
z: number;
|
|
}
|
|
|
|
interface FaceData {
|
|
name: FaceName;
|
|
normal: Vector3D;
|
|
translate: [number, number, number];
|
|
rotate: [number, number, number];
|
|
}
|
|
|
|
// ===================
|
|
// Configuration
|
|
// ===================
|
|
const STICKER_SIZE = 3.25 * REM;
|
|
const CUBE_SIZE = 7.5 * REM;
|
|
const SLOT_SIZE = 4.5 * REM;
|
|
const CUBE_HALF = CUBE_SIZE / 2;
|
|
|
|
const CONFIG = {
|
|
cubeSize: CUBE_SIZE,
|
|
cubeHalf: CUBE_HALF,
|
|
slotSize: SLOT_SIZE,
|
|
perspective: 600,
|
|
|
|
// Physics
|
|
maxSpeed: 15,
|
|
friction: 0.992,
|
|
minVelocity: 0.1,
|
|
idleSpeed: 0.4,
|
|
|
|
// Animation
|
|
flightSpeed: 0.75,
|
|
minFlightDuration: 250,
|
|
maxFlightDuration: 500,
|
|
flightEasing: 'cubic-bezier(0.7, 0, 1, 1)',
|
|
faceSelectionTime: 1,
|
|
|
|
// Square kick
|
|
squareKickStrength: 20,
|
|
kickMomentumDampen: 0.15,
|
|
|
|
// Last slot special kick
|
|
lastKickStrength: 40,
|
|
lastKickFriction: 0.998,
|
|
|
|
// Result braking
|
|
brakingFriction: 0.985,
|
|
brakingDuration: 700,
|
|
|
|
// Craft result
|
|
craftInitialDelay: 350,
|
|
slotFlightInterval: 650,
|
|
craftActionDelay: 200,
|
|
resultFadeDelay: 300,
|
|
resultRotationDuration: 650,
|
|
resultDisplayDuration: 800,
|
|
|
|
// Slot removal
|
|
slotRemoveDuration: 150,
|
|
};
|
|
|
|
const ATTRIBUTE_DIAL_SIZE = 4 * REM;
|
|
|
|
const GRADIENT_COLORS_DEFAULT: [string, string] = ['#425068', '#232E3F'];
|
|
const GRADIENT_COLORS_HIGH_CHANCE: [string, string] = ['#365C61', '#1A2F38'];
|
|
const GRADIENT_COLORS_FAILED: [string, string] = ['#5C362C', '#351B17'];
|
|
|
|
const HIGH_CHANCE_THRESHOLD = 95;
|
|
|
|
const FACES_DATA: FaceData[] = [
|
|
{ name: 'front', normal: { x: 0, y: 0, z: 1 }, translate: [0, 0, CUBE_HALF], rotate: [0, 0, 0] },
|
|
{ name: 'back', normal: { x: 0, y: 0, z: -1 }, translate: [0, 0, -CUBE_HALF], rotate: [0, 180, 0] },
|
|
{ name: 'left', normal: { x: -1, y: 0, z: 0 }, translate: [-CUBE_HALF, 0, 0], rotate: [0, -90, 0] },
|
|
{ name: 'right', normal: { x: 1, y: 0, z: 0 }, translate: [CUBE_HALF, 0, 0], rotate: [0, 90, 0] },
|
|
{ name: 'top', normal: { x: 0, y: -1, z: 0 }, translate: [0, -CUBE_HALF, 0], rotate: [90, 0, 0] },
|
|
{ name: 'bottom', normal: { x: 0, y: 1, z: 0 }, translate: [0, CUBE_HALF, 0], rotate: [-90, 0, 0] },
|
|
];
|
|
|
|
const FACES_BY_NAME = Object.fromEntries(FACES_DATA.map((f) => [f.name, f])) as Record<FaceName, FaceData>;
|
|
|
|
const PATTERN_STICKER_SIZE = 1.875 * REM;
|
|
const PATTERN_STICKER_COLOR = '#FFFFFF';
|
|
|
|
const PatternAttributePreview = memo(({ sticker }: { sticker: ApiSticker }) => {
|
|
const ref = useRef<HTMLDivElement>();
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={styles.patternAttribute}
|
|
style={`width: ${PATTERN_STICKER_SIZE}px; height: ${PATTERN_STICKER_SIZE}px`}
|
|
>
|
|
<StickerView
|
|
containerRef={ref}
|
|
sticker={sticker}
|
|
size={PATTERN_STICKER_SIZE}
|
|
customColor={PATTERN_STICKER_COLOR}
|
|
shouldPreloadPreview
|
|
thumbClassName={styles.patternAttributeThumb}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
type CraftSlotProps = {
|
|
index: number;
|
|
gift?: ApiStarGiftUnique;
|
|
isUsed: boolean;
|
|
isAnimating: boolean;
|
|
isActivated: boolean;
|
|
isRemoving: boolean;
|
|
slotRef: (el: HTMLDivElement | undefined) => void;
|
|
slotInnerRef: (el: HTMLDivElement | undefined) => void;
|
|
onSlotClick: (index: number) => void;
|
|
onRemoveGift: (index: number) => void;
|
|
};
|
|
|
|
const CraftSlot = memo(({
|
|
index,
|
|
gift,
|
|
isUsed,
|
|
isAnimating,
|
|
isActivated,
|
|
isRemoving,
|
|
slotRef,
|
|
slotInnerRef,
|
|
onSlotClick,
|
|
onRemoveGift,
|
|
}: CraftSlotProps) => {
|
|
const giftAttributes = useMemo(() => (gift ? getGiftAttributes(gift) : undefined), [gift]);
|
|
const chancePercent = (gift?.craftChancePermille || 0) / 10;
|
|
|
|
const handleClick = useLastCallback(() => {
|
|
if (!isActivated) {
|
|
onSlotClick(index);
|
|
}
|
|
});
|
|
|
|
const handleRemoveClick = useLastCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onRemoveGift(index);
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className={buildClassName(
|
|
styles.slotWrapper,
|
|
isUsed && styles.used,
|
|
index >= 2 && styles.bottomRow,
|
|
)}
|
|
>
|
|
<div
|
|
ref={slotRef}
|
|
className={buildClassName(
|
|
styles.craftSlot,
|
|
gift && styles.craftSlotFilled,
|
|
isAnimating && styles.animating,
|
|
isRemoving && styles.removing,
|
|
)}
|
|
onClick={handleClick}
|
|
>
|
|
{gift && giftAttributes ? (
|
|
<>
|
|
<div
|
|
ref={slotInnerRef}
|
|
className={styles.slotInner}
|
|
>
|
|
<RadialPatternBackground
|
|
className={styles.slotBackdrop}
|
|
backgroundColors={[giftAttributes.backdrop!.centerColor, giftAttributes.backdrop!.edgeColor]}
|
|
ringsCount={1}
|
|
ovalFactor={1}
|
|
patternSize={12}
|
|
patternIcon={giftAttributes.pattern?.sticker}
|
|
/>
|
|
<AnimatedIconFromSticker
|
|
className={styles.slotSticker}
|
|
sticker={giftAttributes.model?.sticker}
|
|
size={STICKER_SIZE}
|
|
/>
|
|
</div>
|
|
{chancePercent > 0 && (
|
|
<span className={styles.slotChance}>
|
|
{formatPercent(chancePercent, 0)}
|
|
</span>
|
|
)}
|
|
{!isActivated && (
|
|
<button
|
|
type="button"
|
|
className={styles.slotClear}
|
|
onClick={handleRemoveClick}
|
|
>
|
|
<Icon name="close" />
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Icon name="add-filled" className={styles.slotIcon} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
const GiftCraftModal = ({ modal, craftAttributePermilles }: OwnProps & StateProps) => {
|
|
const {
|
|
closeGiftCraftModal, openGiftCraftSelectModal, selectGiftForCraft,
|
|
craftStarGift, requestConfetti, openGiftInfoModal, openGiftCraftInfoModal,
|
|
resetGiftCraftResult, openGiftPreviewModal,
|
|
} = getActions();
|
|
|
|
const lang = useLang();
|
|
const { startViewTransition } = useViewTransition();
|
|
const { createVtnStyle } = useVtn();
|
|
|
|
const isOpen = Boolean(modal);
|
|
const renderingModal = useCurrentOrPrev(modal);
|
|
|
|
const savedGift1 = renderingModal?.gift1;
|
|
const savedGift2 = renderingModal?.gift2;
|
|
const savedGift3 = renderingModal?.gift3;
|
|
const savedGift4 = renderingModal?.gift4;
|
|
const craftResult = renderingModal?.craftResult;
|
|
const previewAttributes = renderingModal?.previewAttributes;
|
|
|
|
// Extract unique gifts from saved gifts
|
|
const gift1 = savedGift1?.gift.type === 'starGiftUnique' ? savedGift1.gift : undefined;
|
|
const gift2 = savedGift2?.gift.type === 'starGiftUnique' ? savedGift2.gift : undefined;
|
|
const gift3 = savedGift3?.gift.type === 'starGiftUnique' ? savedGift3.gift : undefined;
|
|
const gift4 = savedGift4?.gift.type === 'starGiftUnique' ? savedGift4.gift : undefined;
|
|
|
|
const mainGift = gift1 || gift2 || gift3 || gift4;
|
|
const lastMainGift = useCurrentOrPrev(mainGift, true);
|
|
const giftTitle = mainGift?.title || renderingModal?.regularGiftTitle || '';
|
|
|
|
const gifts = useMemo(
|
|
() => [gift1, gift2, gift3, gift4],
|
|
[gift1, gift2, gift3, gift4],
|
|
);
|
|
|
|
const totalChancePermille = useMemo(() => {
|
|
const getChance = (g?: ApiStarGiftUnique) => g?.craftChancePermille || 0;
|
|
return getChance(gift1) + getChance(gift2) + getChance(gift3) + getChance(gift4);
|
|
}, [gift1, gift2, gift3, gift4]);
|
|
|
|
const progressPercent = Math.min(100, totalChancePermille / 10);
|
|
|
|
const fullGiftTitle = useMemo(() => {
|
|
const giftNumber = mainGift?.number;
|
|
if (!giftTitle) return '';
|
|
return giftNumber ? `${giftTitle} #${giftNumber}` : giftTitle;
|
|
}, [giftTitle, mainGift?.number]);
|
|
|
|
const titleGiftSticker = useMemo(() => {
|
|
if (!mainGift) return undefined;
|
|
return getGiftAttributes(mainGift)?.model?.sticker;
|
|
}, [mainGift]);
|
|
|
|
const previewModelStickers = useMemo(() => {
|
|
if (!previewAttributes?.length) return undefined;
|
|
return previewAttributes
|
|
.filter((attr): attr is ApiStarGiftAttributeModel => attr.type === 'model')
|
|
.slice(0, 3)
|
|
.map((attr) => attr.sticker);
|
|
}, [previewAttributes]);
|
|
|
|
// Calculate attribute stats for probability circles
|
|
const attributeStats = useMemo(() => {
|
|
const selectedGifts = gifts.filter((g): g is ApiStarGiftUnique => Boolean(g));
|
|
if (selectedGifts.length === 0) return { backdrops: [], patterns: [] };
|
|
|
|
const backdropCounts = new Map<number, { count: number; attr: ApiStarGiftAttributeBackdrop }>();
|
|
const patternCounts = new Map<string, { count: number; attr: ApiStarGiftAttributePattern }>();
|
|
|
|
for (const gift of selectedGifts) {
|
|
for (const attr of gift.attributes) {
|
|
if (attr.type === 'backdrop') {
|
|
const existing = backdropCounts.get(attr.backdropId);
|
|
if (existing) {
|
|
existing.count++;
|
|
} else {
|
|
backdropCounts.set(attr.backdropId, { count: 1, attr });
|
|
}
|
|
} else if (attr.type === 'pattern') {
|
|
const key = attr.sticker.id;
|
|
const existing = patternCounts.get(key);
|
|
if (existing) {
|
|
existing.count++;
|
|
} else {
|
|
patternCounts.set(key, { count: 1, attr });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const totalGifts = selectedGifts.length;
|
|
const getPermille = (count: number) => {
|
|
if (!craftAttributePermilles?.length || totalGifts === 0) return 0;
|
|
const giftsIndex = Math.min(totalGifts, craftAttributePermilles.length) - 1;
|
|
const perGiftsArray = craftAttributePermilles[giftsIndex];
|
|
if (!Array.isArray(perGiftsArray)) return 0;
|
|
const countIndex = Math.min(count, perGiftsArray.length) - 1;
|
|
return perGiftsArray[countIndex] ?? 0;
|
|
};
|
|
|
|
const backdrops = Array.from(backdropCounts.values())
|
|
.map(({ count, attr }) => ({ attr, count, permille: getPermille(count) }))
|
|
.sort((a, b) => b.count - a.count);
|
|
|
|
const patterns = Array.from(patternCounts.values())
|
|
.map(({ count, attr }) => ({ attr, count, permille: getPermille(count) }))
|
|
.sort((a, b) => b.count - a.count);
|
|
|
|
return { backdrops, patterns };
|
|
}, [gifts, craftAttributePermilles]);
|
|
|
|
// DOM refs
|
|
const cubeRef = useRef<HTMLDivElement>();
|
|
const facesRef = useRef<Record<FaceName, HTMLDivElement | undefined>>({
|
|
front: undefined,
|
|
back: undefined,
|
|
left: undefined,
|
|
right: undefined,
|
|
top: undefined,
|
|
bottom: undefined,
|
|
});
|
|
const slotsRef = useRef<(HTMLDivElement | undefined)[]>([]);
|
|
const slotsInnerRef = useRef<(HTMLDivElement | undefined)[]>([]);
|
|
const backfaceRemovedRef = useRef(false);
|
|
|
|
// Physics state refs (mutable, not triggering re-renders)
|
|
const rotationMatrixRef = useRef<DOMMatrix>(new DOMMatrix());
|
|
const velocityRef = useRef({ x: 0, y: 0 });
|
|
const rafIdRef = useRef<number | undefined>(undefined);
|
|
const tempDeltaMatrixRef = useRef<DOMMatrix>(new DOMMatrix());
|
|
const tempNormalRef = useRef<Vector3D>({ x: 0, y: 0, z: 0 });
|
|
const faceTransformsRef = useRef<Record<FaceName, DOMMatrix> | undefined>(undefined);
|
|
const kickIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
const lastKickedFaceRef = useRef<FaceName | undefined>(undefined);
|
|
const lastSlotIndexRef = useRef<number>(-1);
|
|
const currentFrictionRef = useRef(CONFIG.friction);
|
|
|
|
// Animation state
|
|
const [isActivated, activate, deactivate] = useFlag();
|
|
const [slotsKey, setSlotsKey] = useState(0);
|
|
|
|
const coloredFacesRef = useRef<Set<FaceName>>(new Set());
|
|
const [usedSlots, setUsedSlots] = useState<Set<number>>(() => new Set());
|
|
const [animatingSlots, setAnimatingSlots] = useState<Set<number>>(() => new Set());
|
|
const [removingSlots, setRemovingSlots] = useState<Set<number>>(() => new Set());
|
|
const [craftedGiftFace, setCraftedGiftFace] = useState<{
|
|
face: FaceName;
|
|
gift: ApiStarGiftUnique;
|
|
} | undefined>(undefined);
|
|
const [failedFace, setFailedFace] = useState<{
|
|
face: FaceName;
|
|
burnedCount: number;
|
|
isError?: true;
|
|
} | undefined>(undefined);
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const [isResultRotationComplete, markResultRotationComplete, resetResultRotation] = useFlag();
|
|
const [isRotationStarted, startRotation, resetRotationStarted] = useFlag();
|
|
|
|
// Initialize face transforms
|
|
useEffect(() => {
|
|
if (!faceTransformsRef.current) {
|
|
const transforms: Record<string, DOMMatrix> = {};
|
|
for (const face of FACES_DATA) {
|
|
transforms[face.name] = new DOMMatrix()
|
|
.translateSelf(...face.translate)
|
|
.rotateSelf(...face.rotate);
|
|
}
|
|
faceTransformsRef.current = transforms as Record<FaceName, DOMMatrix>;
|
|
}
|
|
}, []);
|
|
|
|
const resetAnimationState = useLastCallback(() => {
|
|
rotationMatrixRef.current = new DOMMatrix();
|
|
velocityRef.current = { x: 0, y: 0 };
|
|
backfaceRemovedRef.current = false;
|
|
lastKickedFaceRef.current = undefined;
|
|
lastSlotIndexRef.current = -1;
|
|
currentFrictionRef.current = CONFIG.friction;
|
|
|
|
if (rafIdRef.current) {
|
|
cancelAnimationFrame(rafIdRef.current);
|
|
rafIdRef.current = undefined;
|
|
}
|
|
if (kickIntervalRef.current) {
|
|
clearInterval(kickIntervalRef.current);
|
|
kickIntervalRef.current = undefined;
|
|
}
|
|
|
|
coloredFacesRef.current = new Set();
|
|
setUsedSlots(new Set());
|
|
setAnimatingSlots(new Set());
|
|
setRemovingSlots(new Set());
|
|
setRenderingAttributes([]);
|
|
deactivate();
|
|
setCraftedGiftFace(undefined);
|
|
setFailedFace(undefined);
|
|
resetResultRotation();
|
|
resetRotationStarted();
|
|
|
|
requestMutation(() => {
|
|
// Reset cube transform
|
|
if (cubeRef.current) {
|
|
cubeRef.current.style.transform = '';
|
|
}
|
|
|
|
// Reset face backgrounds and backface-visibility
|
|
Object.values(facesRef.current).forEach((face) => {
|
|
if (face) {
|
|
face.style.background = '';
|
|
face.style.backfaceVisibility = '';
|
|
}
|
|
});
|
|
|
|
// Reset slot transforms
|
|
slotsRef.current.forEach((slot) => {
|
|
if (slot) {
|
|
slot.style.transform = '';
|
|
slot.style.transition = '';
|
|
slot.style.position = '';
|
|
slot.style.top = '';
|
|
slot.style.left = '';
|
|
slot.style.marginTop = '';
|
|
slot.style.marginLeft = '';
|
|
slot.classList.remove(styles.craftSlotHidden);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Reset state when modal opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
resetAnimationState();
|
|
}
|
|
}, [isOpen, resetAnimationState]);
|
|
|
|
// Cleanup animation on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (rafIdRef.current) {
|
|
cancelAnimationFrame(rafIdRef.current);
|
|
rafIdRef.current = undefined;
|
|
}
|
|
if (kickIntervalRef.current) {
|
|
clearInterval(kickIntervalRef.current);
|
|
kickIntervalRef.current = undefined;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Fade out slots when result rotation is complete
|
|
useEffect(() => {
|
|
if (isRotationStarted) {
|
|
slotsRef.current.forEach((slot) => {
|
|
if (slot) {
|
|
requestMutation(() => {
|
|
slot.classList.add(styles.craftSlotHidden);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}, [isRotationStarted]);
|
|
|
|
// ===================
|
|
// Physics Functions
|
|
// ===================
|
|
const resetMatrix = (matrix: DOMMatrix) => {
|
|
matrix.a = 1;
|
|
matrix.b = 0;
|
|
matrix.c = 0;
|
|
matrix.d = 1;
|
|
matrix.e = 0;
|
|
matrix.f = 0;
|
|
matrix.m11 = 1;
|
|
matrix.m12 = 0;
|
|
matrix.m13 = 0;
|
|
matrix.m14 = 0;
|
|
matrix.m21 = 0;
|
|
matrix.m22 = 1;
|
|
matrix.m23 = 0;
|
|
matrix.m24 = 0;
|
|
matrix.m31 = 0;
|
|
matrix.m32 = 0;
|
|
matrix.m33 = 1;
|
|
matrix.m34 = 0;
|
|
matrix.m41 = 0;
|
|
matrix.m42 = 0;
|
|
matrix.m43 = 0;
|
|
matrix.m44 = 1;
|
|
};
|
|
|
|
const transformNormalZ = (normal: Vector3D, matrix: DOMMatrix): number => {
|
|
return matrix.m13 * normal.x + matrix.m23 * normal.y + matrix.m33 * normal.z;
|
|
};
|
|
|
|
const transformNormal = (normal: Vector3D, matrix: DOMMatrix, out: Vector3D): Vector3D => {
|
|
out.x = matrix.m11 * normal.x + matrix.m21 * normal.y + matrix.m31 * normal.z;
|
|
out.y = matrix.m12 * normal.x + matrix.m22 * normal.y + matrix.m32 * normal.z;
|
|
out.z = matrix.m13 * normal.x + matrix.m23 * normal.y + matrix.m33 * normal.z;
|
|
return out;
|
|
};
|
|
|
|
const predictRotationMatrix = (ms: number): DOMMatrix => {
|
|
const frames = Math.round(ms / 16.67);
|
|
const { friction, minVelocity } = CONFIG;
|
|
|
|
let predVelX = velocityRef.current.x;
|
|
let predVelY = velocityRef.current.y;
|
|
let predMatrix = DOMMatrix.fromMatrix(rotationMatrixRef.current);
|
|
const tempMatrix = new DOMMatrix();
|
|
|
|
for (let i = 0; i < frames; i++) {
|
|
if (Math.abs(predVelX) > minVelocity || Math.abs(predVelY) > minVelocity) {
|
|
resetMatrix(tempMatrix);
|
|
tempMatrix.rotateSelf(predVelX, predVelY, 0);
|
|
predMatrix = tempMatrix.multiply(predMatrix);
|
|
predVelX *= friction;
|
|
predVelY *= friction;
|
|
}
|
|
}
|
|
|
|
return predMatrix;
|
|
};
|
|
|
|
const getLargestVisibleFace = (
|
|
matrix: DOMMatrix,
|
|
excludeFaces: Set<FaceName>,
|
|
): FaceName | undefined => {
|
|
let bestFace: FaceName | undefined;
|
|
let bestZ = -Infinity;
|
|
|
|
for (const face of FACES_DATA) {
|
|
if (excludeFaces.has(face.name)) continue;
|
|
|
|
const z = transformNormalZ(face.normal, matrix);
|
|
if (z <= 0) continue;
|
|
|
|
if (z > bestZ) {
|
|
bestZ = z;
|
|
bestFace = face.name;
|
|
}
|
|
}
|
|
|
|
return bestFace;
|
|
};
|
|
|
|
const capVelocity = () => {
|
|
const { maxSpeed } = CONFIG;
|
|
const currentSpeed = Math.sqrt(
|
|
velocityRef.current.x * velocityRef.current.x + velocityRef.current.y * velocityRef.current.y,
|
|
);
|
|
if (currentSpeed > maxSpeed) {
|
|
const scale = maxSpeed / currentSpeed;
|
|
velocityRef.current.x *= scale;
|
|
velocityRef.current.y *= scale;
|
|
}
|
|
};
|
|
|
|
const applyKickWithNormal = (flightDirX: number, flightDirY: number, faceNormal: Vector3D, strength: number) => {
|
|
const flightLen = Math.sqrt(flightDirX * flightDirX + flightDirY * flightDirY);
|
|
const fdx = flightDirX / flightLen;
|
|
const fdy = flightDirY / flightLen;
|
|
|
|
const fLen = Math.sqrt(fdx * fdx + fdy * fdy + 1);
|
|
const fx = fdx / fLen;
|
|
const fy = fdy / fLen;
|
|
const fz = -1 / fLen;
|
|
|
|
const torqueX = faceNormal.y * fz - faceNormal.z * fy;
|
|
const torqueY = faceNormal.z * fx - faceNormal.x * fz;
|
|
|
|
velocityRef.current.x *= CONFIG.kickMomentumDampen;
|
|
velocityRef.current.y *= CONFIG.kickMomentumDampen;
|
|
|
|
velocityRef.current.x += torqueX * strength;
|
|
velocityRef.current.y += torqueY * strength;
|
|
capVelocity();
|
|
};
|
|
|
|
// ===================
|
|
// Decelerate to Face
|
|
// ===================
|
|
const decelerateToFace = useLastCallback((targetFace: FaceName, onComplete: NoneToVoidFunction) => {
|
|
const duration = CONFIG.resultRotationDuration;
|
|
const startTime = performance.now();
|
|
|
|
// Keep the actual start matrix (don't extract/reconstruct angles)
|
|
const startMatrix = DOMMatrix.fromMatrix(rotationMatrixRef.current);
|
|
|
|
// Get current rotation direction from velocity
|
|
const yRotationDirection = Math.sign(velocityRef.current.y) || 1;
|
|
|
|
// Calculate target matrix based on face
|
|
let targetMatrix: DOMMatrix;
|
|
switch (targetFace) {
|
|
case 'front':
|
|
targetMatrix = new DOMMatrix(); // identity
|
|
break;
|
|
case 'back':
|
|
targetMatrix = new DOMMatrix().rotateSelf(0, 180, 0);
|
|
break;
|
|
case 'left':
|
|
targetMatrix = new DOMMatrix().rotateSelf(0, 90, 0);
|
|
break;
|
|
case 'right':
|
|
targetMatrix = new DOMMatrix().rotateSelf(0, -90, 0);
|
|
break;
|
|
case 'top':
|
|
targetMatrix = new DOMMatrix().rotateSelf(-90, 0, 0);
|
|
break;
|
|
case 'bottom':
|
|
targetMatrix = new DOMMatrix().rotateSelf(90, 0, 0);
|
|
break;
|
|
default:
|
|
targetMatrix = new DOMMatrix();
|
|
}
|
|
|
|
// Calculate how much extra rotation to add based on current direction
|
|
// This makes the cube "spin through" to the target following inertia
|
|
const extraSpins = yRotationDirection > 0 ? 1 : -1; // Add one full spin in current direction
|
|
const spinMatrix = new DOMMatrix().rotateSelf(0, extraSpins * 360, 0);
|
|
targetMatrix = spinMatrix.multiply(targetMatrix);
|
|
|
|
// Stop the regular animation loop
|
|
if (rafIdRef.current) {
|
|
cancelAnimationFrame(rafIdRef.current);
|
|
rafIdRef.current = undefined;
|
|
}
|
|
|
|
// Use longer duration for the extra spin
|
|
const scaledDuration = duration * 2;
|
|
|
|
const animate = (now: number) => {
|
|
const elapsed = now - startTime;
|
|
const progress = Math.min(elapsed / scaledDuration, 1);
|
|
// Ease out cubic for smooth deceleration
|
|
const eased = 1 - (1 - progress) ** 3;
|
|
|
|
// Interpolate matrix elements and orthonormalize
|
|
const m = new DOMMatrix();
|
|
m.m11 = startMatrix.m11 + (targetMatrix.m11 - startMatrix.m11) * eased;
|
|
m.m12 = startMatrix.m12 + (targetMatrix.m12 - startMatrix.m12) * eased;
|
|
m.m13 = startMatrix.m13 + (targetMatrix.m13 - startMatrix.m13) * eased;
|
|
m.m21 = startMatrix.m21 + (targetMatrix.m21 - startMatrix.m21) * eased;
|
|
m.m22 = startMatrix.m22 + (targetMatrix.m22 - startMatrix.m22) * eased;
|
|
m.m23 = startMatrix.m23 + (targetMatrix.m23 - startMatrix.m23) * eased;
|
|
m.m31 = startMatrix.m31 + (targetMatrix.m31 - startMatrix.m31) * eased;
|
|
m.m32 = startMatrix.m32 + (targetMatrix.m32 - startMatrix.m32) * eased;
|
|
m.m33 = startMatrix.m33 + (targetMatrix.m33 - startMatrix.m33) * eased;
|
|
|
|
// Gram-Schmidt orthonormalization to prevent distortion
|
|
// Column 1 - normalize
|
|
let len = Math.sqrt(m.m11 * m.m11 + m.m21 * m.m21 + m.m31 * m.m31);
|
|
m.m11 /= len;
|
|
m.m21 /= len;
|
|
m.m31 /= len;
|
|
// Column 2 - subtract projection onto column 1, then normalize
|
|
const dot = m.m11 * m.m12 + m.m21 * m.m22 + m.m31 * m.m32;
|
|
m.m12 -= dot * m.m11;
|
|
m.m22 -= dot * m.m21;
|
|
m.m32 -= dot * m.m31;
|
|
len = Math.sqrt(m.m12 * m.m12 + m.m22 * m.m22 + m.m32 * m.m32);
|
|
m.m12 /= len;
|
|
m.m22 /= len;
|
|
m.m32 /= len;
|
|
// Column 3 - cross product of columns 1 and 2
|
|
m.m13 = m.m21 * m.m32 - m.m31 * m.m22;
|
|
m.m23 = m.m31 * m.m12 - m.m11 * m.m32;
|
|
m.m33 = m.m11 * m.m22 - m.m21 * m.m12;
|
|
|
|
rotationMatrixRef.current = m;
|
|
|
|
const transformValue = m.toString();
|
|
requestMutation(() => {
|
|
if (cubeRef.current) {
|
|
cubeRef.current.style.transform = transformValue;
|
|
}
|
|
});
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
onComplete();
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(animate);
|
|
});
|
|
|
|
// ===================
|
|
// Animation Loop
|
|
// ===================
|
|
const lastTimeRef = useRef<number>(0);
|
|
|
|
const startAnimationLoop = useLastCallback(() => {
|
|
if (rafIdRef.current !== undefined) return;
|
|
|
|
const { minVelocity, idleSpeed } = CONFIG;
|
|
const tempMatrix = tempDeltaMatrixRef.current;
|
|
|
|
lastTimeRef.current = performance.now();
|
|
|
|
const update = (now: number) => {
|
|
const dt = (now - lastTimeRef.current) / 16.67;
|
|
lastTimeRef.current = now;
|
|
|
|
const vel = velocityRef.current;
|
|
|
|
// Apply friction (using current friction ref for dynamic control)
|
|
const frictionFactor = currentFrictionRef.current ** dt;
|
|
vel.x *= frictionFactor;
|
|
vel.y *= frictionFactor;
|
|
|
|
// Maintain minimum idle rotation
|
|
const currentSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y);
|
|
if (currentSpeed < idleSpeed) {
|
|
if (currentSpeed > 0.001) {
|
|
const scale = idleSpeed / currentSpeed;
|
|
vel.x *= scale;
|
|
vel.y *= scale;
|
|
} else {
|
|
vel.x = idleSpeed * 0.7;
|
|
vel.y = idleSpeed * 0.7;
|
|
}
|
|
}
|
|
|
|
// Always rotate if we have any velocity
|
|
if (currentSpeed >= minVelocity) {
|
|
resetMatrix(tempMatrix);
|
|
tempMatrix.rotateSelf(vel.x * dt, vel.y * dt, 0);
|
|
rotationMatrixRef.current = tempMatrix.multiply(rotationMatrixRef.current);
|
|
|
|
const transformValue = rotationMatrixRef.current.toString();
|
|
requestMutation(() => {
|
|
if (cubeRef.current) {
|
|
cubeRef.current.style.transform = transformValue;
|
|
}
|
|
});
|
|
}
|
|
|
|
rafIdRef.current = requestAnimationFrame(update);
|
|
};
|
|
|
|
rafIdRef.current = requestAnimationFrame(update);
|
|
});
|
|
|
|
// ===================
|
|
// Slot Flight Animation
|
|
// ===================
|
|
const flySlotToCube = useLastCallback((index: number) => {
|
|
const slot = slotsRef.current[index];
|
|
const cube = cubeRef.current;
|
|
if (!slot || !cube || !faceTransformsRef.current) return;
|
|
|
|
if (animatingSlots.has(index) || usedSlots.has(index)) return;
|
|
|
|
const {
|
|
perspective, cubeSize, slotSize, flightSpeed,
|
|
minFlightDuration, maxFlightDuration, faceSelectionTime, flightEasing,
|
|
} = CONFIG;
|
|
|
|
// Measure DOM positions in a single read phase
|
|
requestMeasure(() => {
|
|
// Get cube's actual center position
|
|
const cubeRect = cube.getBoundingClientRect();
|
|
const cubeCenter = {
|
|
x: cubeRect.left + cubeRect.width / 2,
|
|
y: cubeRect.top + cubeRect.height / 2,
|
|
};
|
|
|
|
const slotRect = slot.getBoundingClientRect();
|
|
const slotCenter = {
|
|
x: slotRect.left + slotRect.width / 2,
|
|
y: slotRect.top + slotRect.height / 2,
|
|
};
|
|
|
|
performSlotFlight(
|
|
index, slot, cube, cubeCenter, slotCenter,
|
|
perspective, cubeSize, slotSize, flightSpeed,
|
|
minFlightDuration, maxFlightDuration, faceSelectionTime, flightEasing,
|
|
);
|
|
});
|
|
});
|
|
|
|
const performSlotFlight = useLastCallback((
|
|
index: number,
|
|
slot: HTMLDivElement,
|
|
cube: HTMLDivElement,
|
|
cubeCenter: { x: number; y: number },
|
|
slotCenter: { x: number; y: number },
|
|
perspective: number,
|
|
cubeSize: number,
|
|
slotSize: number,
|
|
flightSpeed: number,
|
|
minFlightDuration: number,
|
|
maxFlightDuration: number,
|
|
faceSelectionTime: number,
|
|
flightEasing: string,
|
|
) => {
|
|
if (!faceTransformsRef.current) return;
|
|
|
|
const estimatedDistance = Math.sqrt(
|
|
(cubeCenter.x - slotCenter.x) ** 2 + (cubeCenter.y - slotCenter.y) ** 2,
|
|
);
|
|
const animationDuration = Math.max(
|
|
minFlightDuration,
|
|
Math.min(maxFlightDuration, estimatedDistance / flightSpeed),
|
|
);
|
|
|
|
// Predict where cube will be at selection point for face selection
|
|
const predictionTime = animationDuration * faceSelectionTime;
|
|
const predictedRotation = predictRotationMatrix(predictionTime);
|
|
|
|
const targetFace = getLargestVisibleFace(predictedRotation, coloredFacesRef.current);
|
|
if (!targetFace) return;
|
|
|
|
// Predict where face will be at end of flight
|
|
const futureRotation = predictRotationMatrix(animationDuration);
|
|
const faceWorldTransform = futureRotation.multiply(faceTransformsRef.current[targetFace]);
|
|
|
|
const face3dZ = faceWorldTransform.m43;
|
|
const perspectiveScale = perspective / (perspective - face3dZ);
|
|
const faceScreenX = faceWorldTransform.m41 * perspectiveScale;
|
|
const faceScreenY = faceWorldTransform.m42 * perspectiveScale;
|
|
|
|
const rotationOnly = DOMMatrix.fromMatrix(faceWorldTransform);
|
|
rotationOnly.m41 = 0;
|
|
rotationOnly.m42 = 0;
|
|
rotationOnly.m43 = 0;
|
|
|
|
const targetX = cubeCenter.x + faceScreenX - slotCenter.x;
|
|
const targetY = cubeCenter.y + faceScreenY - slotCenter.y;
|
|
const scaleFactor = (cubeSize / slotSize) * perspectiveScale;
|
|
|
|
const finalTransform = new DOMMatrix()
|
|
.translateSelf(targetX, targetY, 0)
|
|
.scaleSelf(scaleFactor)
|
|
.multiplySelf(rotationOnly);
|
|
|
|
const zRotation = (Math.atan2(rotationOnly.m12, rotationOnly.m11) * 180) / Math.PI;
|
|
const snappedZ = Math.round(zRotation / 90) * 90;
|
|
|
|
// Mark as animating
|
|
setAnimatingSlots((prev) => new Set(prev).add(index));
|
|
|
|
// Pre-rotate slot and counter-rotate inner content to avoid visible rotation jump
|
|
const slotInner = slotsInnerRef.current[index];
|
|
const finalTransformStr = finalTransform.toString();
|
|
|
|
requestMutation(() => {
|
|
slot.style.transition = 'none';
|
|
slot.style.transform = `rotateZ(${snappedZ}deg)`;
|
|
if (slotInner) {
|
|
slotInner.style.transition = 'none';
|
|
slotInner.style.transform = `rotateZ(${-snappedZ}deg)`;
|
|
}
|
|
});
|
|
|
|
// Use rAF to trigger animation
|
|
requestAnimationFrame(() => {
|
|
requestMutation(() => {
|
|
slot.style.transition = `transform ${animationDuration}ms ${flightEasing}`;
|
|
slot.style.transform = finalTransformStr;
|
|
if (slotInner) {
|
|
slotInner.style.transition = `transform ${animationDuration}ms ${flightEasing}`;
|
|
slotInner.style.transform = '';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Get face normal for kick
|
|
const faceNormal = transformNormal(FACES_BY_NAME[targetFace].normal, futureRotation, tempNormalRef.current);
|
|
|
|
const onTransitionEnd = () => {
|
|
slot.removeEventListener('transitionend', onTransitionEnd);
|
|
|
|
requestMutation(() => {
|
|
// Remove backface-visibility from all faces on first slot stick
|
|
if (!backfaceRemovedRef.current) {
|
|
backfaceRemovedRef.current = true;
|
|
Object.values(facesRef.current).forEach((face) => {
|
|
if (face) {
|
|
face.style.backfaceVisibility = 'visible';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Move slot into the cube so it rotates with it
|
|
if (cube && slot) {
|
|
cube.appendChild(slot);
|
|
const faceData = FACES_BY_NAME[targetFace];
|
|
slot.style.transition = 'none';
|
|
slot.style.position = 'absolute';
|
|
slot.style.top = '50%';
|
|
slot.style.left = '50%';
|
|
slot.style.marginTop = `${-slotSize / 2}px`;
|
|
slot.style.marginLeft = `${-slotSize / 2}px`;
|
|
const faceScale = cubeSize / slotSize;
|
|
const [tx, ty, tz] = faceData.translate;
|
|
const [rx, ry, rz] = faceData.rotate;
|
|
slot.style.transform = `translate3d(${tx}px, ${ty}px, ${tz}px) `
|
|
+ `rotateX(${rx}deg) rotateY(${ry}deg) rotateZ(${rz}deg) scale(${faceScale})`;
|
|
}
|
|
});
|
|
|
|
coloredFacesRef.current.add(targetFace);
|
|
|
|
// Check if this is the last slot - apply stronger kick and reduce friction
|
|
const isLastSlot = index === lastSlotIndexRef.current;
|
|
const kickStrength = isLastSlot ? CONFIG.lastKickStrength : CONFIG.squareKickStrength;
|
|
|
|
if (isLastSlot) {
|
|
// Reduce friction for faster spinning
|
|
currentFrictionRef.current = CONFIG.lastKickFriction;
|
|
}
|
|
|
|
applyKickWithNormal(targetX, targetY, { ...faceNormal }, kickStrength);
|
|
startAnimationLoop();
|
|
|
|
setAnimatingSlots((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(index);
|
|
return next;
|
|
});
|
|
setUsedSlots((prev) => new Set(prev).add(index));
|
|
};
|
|
|
|
slot.addEventListener('transitionend', onTransitionEnd);
|
|
});
|
|
|
|
const handleClose = useLastCallback(() => {
|
|
if (isActivated && !craftResult) return;
|
|
|
|
if (rafIdRef.current) {
|
|
cancelAnimationFrame(rafIdRef.current);
|
|
rafIdRef.current = undefined;
|
|
}
|
|
closeGiftCraftModal();
|
|
});
|
|
|
|
const handleSlotClick = useLastCallback((index: number) => {
|
|
const slotGift = gifts[index];
|
|
if (slotGift) {
|
|
selectGiftForCraft({ slotIndex: index });
|
|
} else {
|
|
openGiftCraftSelectModal({ slotIndex: index });
|
|
}
|
|
});
|
|
|
|
const handleCraftClick = useLastCallback(() => {
|
|
// Find all slots with gifts that haven't been used yet
|
|
const giftedIndices = gifts
|
|
.map((g, i) => (g && !usedSlots.has(i) && !animatingSlots.has(i) ? i : -1))
|
|
.filter((i) => i !== -1);
|
|
|
|
if (giftedIndices.length === 0) return;
|
|
|
|
// Track the last slot index for special handling
|
|
lastSlotIndexRef.current = giftedIndices[giftedIndices.length - 1];
|
|
|
|
// Calculate total animation time
|
|
const totalAnimationTime = CONFIG.craftInitialDelay
|
|
+ giftedIndices.length * CONFIG.slotFlightInterval + CONFIG.craftActionDelay;
|
|
|
|
const totalResultTime = CONFIG.brakingDuration + CONFIG.resultRotationDuration + CONFIG.resultDisplayDuration;
|
|
const endHeavyAnimation = beginHeavyAnimation(totalAnimationTime + totalResultTime);
|
|
|
|
// Activate the animation
|
|
activate();
|
|
|
|
// Trigger each slot to fly with interval (after initial delay for progress animation)
|
|
giftedIndices.forEach((index, i) => {
|
|
setTimeout(() => {
|
|
flySlotToCube(index);
|
|
}, CONFIG.craftInitialDelay + (i + 1) * CONFIG.slotFlightInterval);
|
|
});
|
|
|
|
// Call craft action after all slots have flown
|
|
setTimeout(() => {
|
|
craftStarGift();
|
|
}, totalAnimationTime);
|
|
|
|
// End heavy animation after result is shown
|
|
setTimeout(() => {
|
|
endHeavyAnimation();
|
|
}, totalAnimationTime + totalResultTime);
|
|
});
|
|
|
|
// Handle craft result
|
|
useEffect(() => {
|
|
if (!craftResult) return;
|
|
|
|
// Start braking - increase friction to slow down
|
|
currentFrictionRef.current = CONFIG.brakingFriction;
|
|
|
|
if (craftResult.success) {
|
|
// Find a free face for the crafted gift
|
|
const allFaces: FaceName[] = ['front', 'back', 'left', 'right', 'top', 'bottom'];
|
|
const freeFace = allFaces.find((f) => !coloredFacesRef.current.has(f)) || 'front';
|
|
|
|
// Set the crafted gift on that face
|
|
setCraftedGiftFace({ face: freeFace, gift: craftResult.gift });
|
|
|
|
// Let it spin with braking for a while, then rotate to result
|
|
setTimeout(() => {
|
|
startRotation();
|
|
decelerateToFace(freeFace, () => {
|
|
markResultRotationComplete();
|
|
});
|
|
}, CONFIG.brakingDuration);
|
|
|
|
// After braking + rotation + display duration, close and show result
|
|
setTimeout(() => {
|
|
closeGiftCraftModal();
|
|
requestConfetti({ withStars: true });
|
|
openGiftInfoModal({ gift: craftResult.gift });
|
|
}, CONFIG.brakingDuration + CONFIG.resultRotationDuration + CONFIG.resultDisplayDuration);
|
|
} else {
|
|
// Fail case - show broken gift animation
|
|
const allFaces: FaceName[] = ['front', 'back', 'left', 'right', 'top', 'bottom'];
|
|
const freeFace = allFaces.find((f) => !coloredFacesRef.current.has(f)) || 'front';
|
|
|
|
// Count burned gifts
|
|
const burnedCount = gifts.filter(Boolean).length;
|
|
const isError = !craftResult.success && craftResult.isError;
|
|
|
|
// Set the failed face
|
|
setFailedFace({ face: freeFace, burnedCount, isError: isError || undefined });
|
|
|
|
// Let it spin with braking for a while, then rotate to failed face
|
|
setTimeout(() => {
|
|
startRotation();
|
|
decelerateToFace(freeFace, () => {
|
|
markResultRotationComplete();
|
|
});
|
|
}, CONFIG.brakingDuration);
|
|
}
|
|
}, [craftResult, gifts, closeGiftCraftModal, requestConfetti, openGiftInfoModal, decelerateToFace]);
|
|
|
|
const hasSelectedGifts = useMemo(
|
|
() => gifts.some((g, i) => g && !usedSlots.has(i)),
|
|
[gifts, usedSlots],
|
|
);
|
|
|
|
const allAttributes = useMemo(
|
|
() => [...attributeStats.backdrops, ...attributeStats.patterns],
|
|
[attributeStats.backdrops, attributeStats.patterns],
|
|
);
|
|
|
|
const [renderingAttributes, setRenderingAttributes] = useState(allAttributes);
|
|
|
|
useEffect(() => {
|
|
if (allAttributes === renderingAttributes) return;
|
|
|
|
const getAttrKey = (a: { attr: ApiStarGiftAttributeBackdrop | ApiStarGiftAttributePattern }) => (
|
|
a.attr.type === 'backdrop' ? `b-${a.attr.backdropId}` : `p-${a.attr.sticker.id}`
|
|
);
|
|
|
|
const prevKeys = renderingAttributes.map(getAttrKey);
|
|
const newKeys = allAttributes.map(getAttrKey);
|
|
const hasOrderChanged = prevKeys.length !== newKeys.length
|
|
|| prevKeys.some((key, i) => key !== newKeys[i]);
|
|
|
|
if (hasOrderChanged) {
|
|
startViewTransition(VTT_CRAFT_ATTRIBUTES, () => {
|
|
setRenderingAttributes(allAttributes);
|
|
});
|
|
} else {
|
|
setRenderingAttributes(allAttributes);
|
|
}
|
|
}, [allAttributes, renderingAttributes, startViewTransition]);
|
|
|
|
const burnedGifts = useMemo(
|
|
() => gifts.filter((g): g is ApiStarGiftUnique => Boolean(g)),
|
|
[gifts],
|
|
);
|
|
|
|
const handleRemoveGift = useLastCallback((index: number) => {
|
|
setRemovingSlots(new Set(removingSlots).add(index));
|
|
|
|
setTimeout(() => {
|
|
selectGiftForCraft({ slotIndex: index });
|
|
const newSlots = new Set(removingSlots);
|
|
newSlots.delete(index);
|
|
setRemovingSlots(newSlots);
|
|
}, CONFIG.slotRemoveDuration);
|
|
});
|
|
|
|
const slotRefCallbacks = useMemo(() => [0, 1, 2, 3].map((i) => (el: HTMLDivElement | undefined) => {
|
|
slotsRef.current[i] = el;
|
|
}), []);
|
|
|
|
const slotInnerRefCallbacks = useMemo(() => [0, 1, 2, 3].map((i) => (el: HTMLDivElement | undefined) => {
|
|
slotsInnerRef.current[i] = el;
|
|
}), []);
|
|
|
|
function renderCraftSlot(index: number) {
|
|
return (
|
|
<CraftSlot
|
|
key={`${slotsKey}-${index}`}
|
|
index={index}
|
|
gift={gifts[index]}
|
|
isUsed={usedSlots.has(index)}
|
|
isAnimating={animatingSlots.has(index)}
|
|
isActivated={isActivated}
|
|
isRemoving={removingSlots.has(index)}
|
|
slotRef={slotRefCallbacks[index]}
|
|
slotInnerRef={slotInnerRefCallbacks[index]}
|
|
onSlotClick={handleSlotClick}
|
|
onRemoveGift={handleRemoveGift}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function renderFaceContent(faceName: FaceName) {
|
|
// Check if this face has the crafted gift (success)
|
|
if (craftedGiftFace?.face === faceName) {
|
|
const giftAttributes = getGiftAttributes(craftedGiftFace.gift);
|
|
if (giftAttributes?.backdrop) {
|
|
return (
|
|
<div className={styles.craftedGiftFace}>
|
|
<RadialPatternBackground
|
|
className={styles.craftedGiftBackdrop}
|
|
backgroundColors={[giftAttributes.backdrop.centerColor, giftAttributes.backdrop.edgeColor]}
|
|
patternIcon={giftAttributes.pattern?.sticker}
|
|
ringsCount={1}
|
|
ovalFactor={1}
|
|
patternSize={12}
|
|
/>
|
|
<AnimatedIconFromSticker
|
|
className={styles.craftedGiftSticker}
|
|
sticker={giftAttributes.model?.sticker}
|
|
size={STICKER_SIZE * 1.2}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if this face shows the failed result
|
|
if (failedFace?.face === faceName) {
|
|
return (
|
|
<div className={styles.failedGiftFace}>
|
|
<RadialProgress className={styles.progressRing} progress={0} />
|
|
<AnimatedIconWithPreview
|
|
tgsUrl={LOCAL_TGS_URLS.BrokenGift}
|
|
previewUrl={LOCAL_TGS_PREVIEW_URLS.BrokenGift}
|
|
size={STICKER_SIZE}
|
|
noLoop
|
|
/>
|
|
{!failedFace.isError && (
|
|
<span className={styles.burnedCount}>{failedFace.burnedCount}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show progress animation on front face during crafting
|
|
if (faceName === 'front' && isActivated && !craftedGiftFace && !failedFace) {
|
|
return (
|
|
<AnimatedIcon
|
|
tgsUrl={LOCAL_TGS_URLS.CraftProgress}
|
|
play
|
|
noLoop={false}
|
|
size={50}
|
|
forceAlways
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <Icon name="craft" className={styles.faceIcon} />;
|
|
}
|
|
|
|
function render3DCube() {
|
|
return (
|
|
<div ref={cubeRef} className={styles.cube}>
|
|
<div className={styles.corners}>
|
|
<div className={styles.corner} />
|
|
<div className={styles.corner} />
|
|
<div className={styles.corner} />
|
|
<div className={styles.corner} />
|
|
<div className={styles.corner} />
|
|
<div className={styles.corner} />
|
|
<div className={styles.corner} />
|
|
<div className={styles.corner} />
|
|
</div>
|
|
{FACES_DATA.map((face) => {
|
|
const resultFace = craftedGiftFace?.face || failedFace?.face;
|
|
const shouldHide = isRotationStarted && resultFace !== face.name;
|
|
const isFailed = failedFace?.face === face.name;
|
|
return (
|
|
<div
|
|
key={face.name}
|
|
ref={(el) => { facesRef.current[face.name] = el || undefined; }}
|
|
className={buildClassName(
|
|
styles.face,
|
|
shouldHide && styles.faceHidden,
|
|
isFailed && styles.faceFailed,
|
|
progressPercent > HIGH_CHANCE_THRESHOLD && styles.faceHighChance,
|
|
)}
|
|
data-face={face.name}
|
|
>
|
|
{renderFaceContent(face.name)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderCenterAnvil() {
|
|
return (
|
|
<div
|
|
className={buildClassName(styles.centerAnvil,
|
|
progressPercent > HIGH_CHANCE_THRESHOLD
|
|
&& styles.centerAnvilHighChance)}
|
|
>
|
|
<RadialProgress className={styles.progressRing} progress={progressPercent} />
|
|
<Icon name="craft" className={styles.anvilIcon} />
|
|
<AnimatedCounter className={styles.percentage} text={formatPercent(progressPercent, 0)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderHeader() {
|
|
return (
|
|
<div className={styles.header}>
|
|
<div className={buildClassName(styles.slotsGrid, isActivated && styles.activated)}>
|
|
{renderCraftSlot(0)}
|
|
{renderCraftSlot(1)}
|
|
{isActivated ? (
|
|
<div className={styles.cubeWrapper}>
|
|
{render3DCube()}
|
|
</div>
|
|
) : (
|
|
renderCenterAnvil()
|
|
)}
|
|
{renderCraftSlot(2)}
|
|
{renderCraftSlot(3)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderAttributeDial(
|
|
attr: ApiStarGiftAttributeBackdrop | ApiStarGiftAttributePattern,
|
|
permille: number,
|
|
) {
|
|
const percent = permille / 10;
|
|
const percentText = formatPercent(percent, 0);
|
|
const attrKey = attr.type === 'backdrop' ? `backdrop-${attr.backdropId}` : `pattern-${attr.sticker.id}`;
|
|
|
|
return (
|
|
<div
|
|
key={attrKey}
|
|
className={styles.attributeCircle}
|
|
style={createVtnStyle(attrKey, 'craftAttribute')}
|
|
>
|
|
<div className={styles.attributeContent}>
|
|
<RadialProgress className={styles.attributeRing} progress={percent} size={ATTRIBUTE_DIAL_SIZE} />
|
|
{attr.type === 'backdrop' ? (
|
|
<div
|
|
className={styles.colorAttribute}
|
|
style={`background: linear-gradient(135deg, ${attr.centerColor} 0%, ${attr.edgeColor} 100%)`}
|
|
/>
|
|
) : (
|
|
<PatternAttributePreview sticker={attr.sticker} />
|
|
)}
|
|
</div>
|
|
<AnimatedCounter className={styles.attributePercent} text={percentText} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderInfo() {
|
|
return (
|
|
<div className={styles.infoSection}>
|
|
<p className={styles.infoDescription}>
|
|
{lang('GiftCraftDescription', {
|
|
giftLine: (
|
|
<span className={styles.giftLine}>
|
|
{titleGiftSticker && (
|
|
<AnimatedIconFromSticker
|
|
className={styles.giftIcon}
|
|
sticker={titleGiftSticker}
|
|
size={REM}
|
|
/>
|
|
)}
|
|
<strong>{fullGiftTitle}</strong>
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
withNodes: true,
|
|
renderTextFilters: ['br'],
|
|
withMarkdown: true,
|
|
},
|
|
)}
|
|
</p>
|
|
<p className={styles.infoWarning}>
|
|
{lang('GiftCraftWarning', undefined, {
|
|
withNodes: true,
|
|
renderTextFilters: ['br'],
|
|
withMarkdown: true,
|
|
})}
|
|
</p>
|
|
{previewModelStickers && previewModelStickers.length > 0 && (
|
|
<Button
|
|
size="tiny"
|
|
color="transparentBlured"
|
|
pill
|
|
fluid
|
|
className={styles.viewAllButton}
|
|
onClick={handleViewAllVariants}
|
|
>
|
|
{previewModelStickers.map((sticker) => (
|
|
<CustomEmoji
|
|
key={sticker.id}
|
|
sticker={sticker}
|
|
noPlay
|
|
/>
|
|
))}
|
|
<span className={styles.viewAllText}>
|
|
{lang('GiftCraftViewAll', undefined, {
|
|
withNodes: true,
|
|
specialReplacement: getNextArrowReplacement(),
|
|
})}
|
|
</span>
|
|
</Button>
|
|
)}
|
|
{renderingAttributes.length > 0 && (
|
|
<div className={styles.attributeCircles}>
|
|
{renderingAttributes.map(({ attr, permille }) => renderAttributeDial(attr, permille))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderCraftingInfo() {
|
|
return (
|
|
<div className={styles.craftingSection}>
|
|
<h4 className={styles.craftingTitle}>{lang('GiftCraftingTitle')}</h4>
|
|
<p className={styles.craftingGiftName}>{fullGiftTitle}</p>
|
|
<p className={styles.craftingWarning}>
|
|
{lang('GiftCraftWarning', undefined, {
|
|
withNodes: true,
|
|
renderTextFilters: ['br'],
|
|
withMarkdown: true,
|
|
})}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderFailedInfo() {
|
|
if (failedFace?.isError) {
|
|
return (
|
|
<div className={styles.failedSection}>
|
|
<p className={styles.failedTitle}>
|
|
{lang('SomethingWentWrong')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const burnedCount = burnedGifts.length;
|
|
|
|
return (
|
|
<div className={styles.failedSection}>
|
|
<h4 className={styles.failedTitle}>{lang('GiftCraftFailedTitle')}</h4>
|
|
<p className={styles.failedDescription}>
|
|
{lang('GiftCraftFailedDescription', { count: burnedCount }, {
|
|
pluralValue: burnedCount,
|
|
withNodes: true,
|
|
renderTextFilters: ['br'],
|
|
withMarkdown: true,
|
|
})}
|
|
</p>
|
|
<div className={styles.burnedGifts}>
|
|
{burnedGifts.map((gift) => {
|
|
const giftAttributes = getGiftAttributes(gift);
|
|
if (!giftAttributes) return undefined;
|
|
return (
|
|
<div key={gift.id} className={styles.burnedGift}>
|
|
<div className={styles.burnedGiftInner}>
|
|
<RadialPatternBackground
|
|
className={styles.burnedGiftBackdrop}
|
|
backgroundColors={[
|
|
giftAttributes.backdrop!.centerColor,
|
|
giftAttributes.backdrop!.edgeColor,
|
|
]}
|
|
patternIcon={giftAttributes.pattern!.sticker}
|
|
ringsCount={1}
|
|
ovalFactor={1}
|
|
patternSize={8}
|
|
/>
|
|
<AnimatedIconFromSticker
|
|
className={styles.burnedGiftSticker}
|
|
sticker={giftAttributes.model?.sticker}
|
|
size={STICKER_SIZE * 0.8}
|
|
/>
|
|
</div>
|
|
<GiftRibbon
|
|
className={styles.burnedGiftBadge}
|
|
textClassName={styles.burnedGiftBadgeText}
|
|
color="red"
|
|
size={40}
|
|
text={`#${gift.number}`}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderInfoContent() {
|
|
let activeKey;
|
|
let content;
|
|
|
|
if (failedFace) {
|
|
activeKey = 2;
|
|
content = renderFailedInfo();
|
|
} else if (isActivated) {
|
|
activeKey = 1;
|
|
content = renderCraftingInfo();
|
|
} else {
|
|
activeKey = 0;
|
|
content = renderInfo();
|
|
}
|
|
|
|
return (
|
|
<Transition
|
|
className={styles.infoTransition}
|
|
name="fade"
|
|
activeKey={activeKey}
|
|
>
|
|
{content}
|
|
</Transition>
|
|
);
|
|
}
|
|
|
|
const handleRetryClick = useLastCallback(() => {
|
|
resetGiftCraftResult();
|
|
resetAnimationState();
|
|
setSlotsKey((k) => k + 1);
|
|
});
|
|
|
|
function renderCraftButton() {
|
|
const isFailed = Boolean(failedFace);
|
|
const shouldRenderHighChanceButton = hasSelectedGifts && !isActivated && !isFailed
|
|
&& progressPercent > HIGH_CHANCE_THRESHOLD;
|
|
const buttonText = isFailed
|
|
? lang('GiftCraftNewGift')
|
|
: lang('GiftCraftButton', { giftName: fullGiftTitle });
|
|
|
|
let activeKey = 0;
|
|
if (isFailed) activeKey = 2;
|
|
else if (isActivated) activeKey = 3;
|
|
else if (hasSelectedGifts) activeKey = 1;
|
|
|
|
return (
|
|
<div className={styles.footer}>
|
|
<Button
|
|
className={buildClassName(
|
|
styles.craftButton,
|
|
isActivated && !isFailed && styles.craftButtonCrafting,
|
|
shouldRenderHighChanceButton && styles.craftButtonHighChance,
|
|
)}
|
|
size="smaller"
|
|
color={isFailed ? 'danger' : undefined}
|
|
disabled={!isFailed && (!hasSelectedGifts || isActivated)}
|
|
onClick={isFailed ? handleRetryClick : handleCraftClick}
|
|
>
|
|
<Transition name="fade" activeKey={activeKey}>
|
|
{(isActivated && !isFailed) ? (
|
|
<span className={styles.craftButtonTitle}>
|
|
{lang('GiftCraftSuccessChance', { percent: formatPercent(progressPercent, 0) })}
|
|
</span>
|
|
) : (
|
|
<div className={styles.craftButtonContent}>
|
|
<span className={styles.craftButtonTitle}>{buttonText}</span>
|
|
{!isFailed && (
|
|
<span className={styles.craftButtonSubtitle}>
|
|
{hasSelectedGifts
|
|
? lang('GiftCraftSuccessChance', { percent: formatPercent(progressPercent, 0) })
|
|
: lang(
|
|
'GiftCraftEmptyHint',
|
|
{ button: <Icon name="add-filled" className={styles.emptyHintIcon} /> },
|
|
{ withNodes: true },
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Transition>
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleHelpClick = useLastCallback(() => {
|
|
if (!lastMainGift || (isActivated && !craftResult)) return;
|
|
openGiftCraftInfoModal({ gift: lastMainGift });
|
|
});
|
|
|
|
const handleViewAllVariants = useLastCallback(() => {
|
|
if (!lastMainGift) return;
|
|
openGiftPreviewModal({ originGift: lastMainGift, shouldShowCraftableOnStart: true });
|
|
});
|
|
|
|
const helpButton = useMemo(() => (
|
|
<Button
|
|
className={styles.helpButton}
|
|
round
|
|
color="translucent-white"
|
|
size="tiny"
|
|
ariaLabel={lang('GiftCraftHelp')}
|
|
onClick={handleHelpClick}
|
|
>
|
|
<Icon name="help" />
|
|
</Button>
|
|
), [lang, handleHelpClick]);
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={handleClose}
|
|
className={styles.modal}
|
|
contentClassName={styles.content}
|
|
hasAbsoluteCloseButton
|
|
headerRightToolBar={helpButton}
|
|
isSlim
|
|
isLowStackPriority
|
|
absoluteCloseButtonColor="translucent-white"
|
|
>
|
|
<h3 className={styles.title}>{lang('GiftCraftTitle')}</h3>
|
|
<Transition
|
|
className={styles.patternOverlay}
|
|
slideClassName={styles.patternSlide}
|
|
name="fade"
|
|
activeKey={failedFace ? 1 : (progressPercent > HIGH_CHANCE_THRESHOLD ? 2 : 0)}
|
|
>
|
|
<RadialPatternBackground
|
|
className={styles.patternBackground}
|
|
patternUrl={craftPatternUrl}
|
|
backgroundColors={
|
|
failedFace
|
|
? GRADIENT_COLORS_FAILED
|
|
: (progressPercent > HIGH_CHANCE_THRESHOLD ? GRADIENT_COLORS_HIGH_CHANCE : GRADIENT_COLORS_DEFAULT)
|
|
}
|
|
patternColor={failedFace ? '#311A15' : (progressPercent > HIGH_CHANCE_THRESHOLD ? '#142A2C' : '#242F42')}
|
|
yPosition={9.5 * REM}
|
|
maxRadius={0.3}
|
|
patternSize={22}
|
|
ovalFactor={1.2}
|
|
ringsCount={2}
|
|
/>
|
|
</Transition>
|
|
{renderHeader()}
|
|
{renderInfoContent()}
|
|
{renderCraftButton()}
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default memo(withGlobal<OwnProps>(
|
|
(global): StateProps => {
|
|
return {
|
|
craftAttributePermilles: global.appConfig?.stargiftsCraftAttributePermilles,
|
|
};
|
|
},
|
|
)(GiftCraftModal));
|