From 0e62acc2a61292cd8c1fe246fe1ece4b3bb425d1 Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 22 Jan 2026 09:37:38 +0400 Subject: [PATCH] Start craft animation. --- Telegram/CMakeLists.txt | 2 + Telegram/Resources/langs/lang.strings | 1 - .../boxes/star_gift_craft_animation.cpp | 1002 +++++++++++++++++ .../boxes/star_gift_craft_animation.h | 114 ++ .../SourceFiles/boxes/star_gift_craft_box.cpp | 282 ++--- .../window/window_session_controller.cpp | 30 + 6 files changed, 1264 insertions(+), 167 deletions(-) create mode 100644 Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp create mode 100644 Telegram/SourceFiles/boxes/star_gift_craft_animation.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 1c2fb9e8bf..f23515914e 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -337,6 +337,8 @@ PRIVATE boxes/star_gift_auction_box.h boxes/star_gift_box.cpp boxes/star_gift_box.h + boxes/star_gift_craft_animation.cpp + boxes/star_gift_craft_animation.h boxes/star_gift_craft_box.cpp boxes/star_gift_craft_box.h boxes/star_gift_preview_box.cpp diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index c693ac1d01..54143789b9 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -4098,7 +4098,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_gift_collection_reorder_exit" = "Apply Reorder"; "lng_gift_collection_remove_from" = "Remove from Collection"; "lng_gift_locked_title" = "Gift Locked"; -"lng_gift_craft_info_level" = "Level {n} Gift"; "lng_gift_craft_info_title" = "Gift Crafting"; "lng_gift_craft_info_about" = "Use your existing gifts to craft new ones."; "lng_gift_craft_combine_title" = "Combine Gifts"; diff --git a/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp b/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp new file mode 100644 index 0000000000..43da306743 --- /dev/null +++ b/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp @@ -0,0 +1,1002 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "boxes/star_gift_craft_animation.h" + +#include "info/peer_gifts/info_peer_gifts_common.h" +#include "ui/image/image_prepare.h" +#include "ui/top_background_gradient.h" +#include "ui/painter.h" +#include "ui/wrap/vertical_layout.h" +#include "styles/style_boxes.h" +#include "styles/style_layers.h" + +namespace Ui { +namespace { + +using namespace Info::PeerGifts; + +[[nodiscard]] QImage CreateBgGradient( + QSize size, + const Data::UniqueGiftBackdrop &backdrop) { + const auto ratio = style::DevicePixelRatio(); + auto result = QImage(size * ratio, QImage::Format_ARGB32_Premultiplied); + result.setDevicePixelRatio(ratio); + + auto p = QPainter(&result); + auto hq = PainterHighQualityEnabler(p); + auto gradient = QRadialGradient( + QPoint(size.width() / 2, size.height() / 2), + size.height() / 2); + gradient.setStops({ + { 0., backdrop.centerColor }, + { 1., backdrop.edgeColor }, + }); + p.setBrush(gradient); + p.setPen(Qt::NoPen); + p.drawRect(0, 0, size.width(), size.height()); + p.end(); + + const auto mask = Images::CornersMask(st::boxRadius); + return Images::Round(std::move(result), mask); +} + +[[nodiscard]] std::optional> ComputeCubeFaceCorners( + QPointF center, + float64 size, + float64 rotationX, + float64 rotationY, + int faceIndex) { + struct Vec3 { + float64 x, y, z; + + Vec3 operator+(const Vec3 &o) const { + return { x + o.x, y + o.y, z + o.z }; + } + Vec3 operator-(const Vec3 &o) const { + return { x - o.x, y - o.y, z - o.z }; + } + Vec3 operator*(float64 s) const { + return { x * s, y * s, z * s }; + } + [[nodiscard]] Vec3 cross(const Vec3 &o) const { + return { + y * o.z - z * o.y, + z * o.x - x * o.z, + x * o.y - y * o.x + }; + } + [[nodiscard]] float64 dot(const Vec3 &o) const { + return x * o.x + y * o.y + z * o.z; + } + [[nodiscard]] Vec3 normalized() const { + const auto len = std::sqrt(x * x + y * y + z * z); + return (len > 0.) ? Vec3{ x / len, y / len, z / len } : *this; + } + }; + + const auto rotateY = [](Vec3 v, float64 angle) { + const auto c = std::cos(angle); + const auto s = std::sin(angle); + return Vec3{ v.x * c + v.z * s, v.y, -v.x * s + v.z * c }; + }; + const auto rotateX = [](Vec3 v, float64 angle) { + const auto c = std::cos(angle); + const auto s = std::sin(angle); + return Vec3{ v.x, v.y * c - v.z * s, v.y * s + v.z * c }; + }; + + const auto half = size / 2.; + constexpr auto kFocalLength = 800.; + + struct FaceDefinition { + std::array corners; + Vec3 normal; + }; + + const auto faces = std::array{{ + {{{{ -half, -half, -half }, + { half, -half, -half }, + { half, half, -half }, + { -half, half, -half }}}, + { 0., 0., -1. }}, + + {{{{ half, -half, half }, + { -half, -half, half }, + { -half, half, half }, + { half, half, half }}}, + { 0., 0., 1. }}, + + {{{{ -half, -half, half }, + { -half, -half, -half }, + { -half, half, -half }, + { -half, half, half }}}, + { -1., 0., 0. }}, + + {{{{ half, -half, -half }, + { half, -half, half }, + { half, half, half }, + { half, half, -half }}}, + { 1., 0., 0. }}, + + {{{{ -half, -half, half }, + { half, -half, half }, + { half, -half, -half }, + { -half, -half, -half }}}, + { 0., -1., 0. }}, + + {{{{ -half, half, -half }, + { half, half, -half }, + { half, half, half }, + { -half, half, half }}}, + { 0., 1., 0. }}, + }}; + + if (faceIndex < 0 || faceIndex >= 6) { + return std::nullopt; + } + + const auto &face = faces[faceIndex]; + + auto transformedNormal = rotateX(rotateY(face.normal, rotationY), rotationX); + if (transformedNormal.z >= 0.) { + return std::nullopt; + } + + auto result = std::array(); + for (auto i = 0; i != 4; ++i) { + auto p = rotateX(rotateY(face.corners[i], rotationY), rotationX); + + const auto viewZ = p.z + half + kFocalLength; + if (viewZ <= 0.) { + return std::nullopt; + } + + const auto scale = kFocalLength / viewZ; + result[i] = QPointF( + center.x() + p.x * scale, + center.y() + p.y * scale); + } + + return result; +} + +void PaintCubeFace( + QPainter &p, + const QImage &face, + const std::array &corners, + int rotation = 0) { + if (face.isNull()) { + return; + } + + const auto ratio = style::DevicePixelRatio(); + const auto w = face.width() / ratio; + const auto h = face.height() / ratio; + + const auto srcRect = QPolygonF({ + QPointF(0, 0), + QPointF(w, 0), + QPointF(w, h), + QPointF(0, h) + }); + + const auto rotationSteps = (rotation / 90) % 4; + auto rotatedCorners = corners; + for (auto i = 0; i < rotationSteps; ++i) { + rotatedCorners = { + rotatedCorners[1], + rotatedCorners[2], + rotatedCorners[3], + rotatedCorners[0], + }; + } + + const auto dstRect = QPolygonF({ + rotatedCorners[0], + rotatedCorners[1], + rotatedCorners[2], + rotatedCorners[3] + }); + + QTransform transform; + if (!QTransform::quadToQuad(srcRect, dstRect, transform)) { + return; + } + + PainterHighQualityEnabler hq(p); + p.save(); + p.setTransform(transform, true); + p.drawImage(QRectF(0, 0, w, h), face); + p.restore(); +} + +[[nodiscard]] std::vector GetVisibleCubeFaces( + float64 rotationX, + float64 rotationY) { + struct Vec3 { + float64 x, y, z; + + Vec3 operator*(float64 s) const { + return { x * s, y * s, z * s }; + } + }; + + const auto rotateY = [](Vec3 v, float64 angle) { + const auto c = std::cos(angle); + const auto s = std::sin(angle); + return Vec3{ v.x * c + v.z * s, v.y, -v.x * s + v.z * c }; + }; + const auto rotateX = [](Vec3 v, float64 angle) { + const auto c = std::cos(angle); + const auto s = std::sin(angle); + return Vec3{ v.x, v.y * c - v.z * s, v.y * s + v.z * c }; + }; + + constexpr auto normals = std::array{{ + { 0., 0., -1. }, + { 0., 0., 1. }, + { -1., 0., 0. }, + { 1., 0., 0. }, + { 0., -1., 0. }, + { 0., 1., 0. }, + }}; + + struct FaceDepth { + int index; + float64 z; + }; + auto visible = std::vector(); + visible.reserve(3); + + for (auto i = 0; i != 6; ++i) { + auto transformed = rotateX(rotateY(normals[i], rotationY), rotationX); + if (transformed.z < 0.) { + visible.push_back({ i, transformed.z }); + } + } + + ranges::sort(visible, ranges::greater(), &FaceDepth::z); + + auto result = std::vector(); + result.reserve(visible.size()); + for (const auto &face : visible) { + result.push_back(face.index); + } + return result; +} + +void PaintCube( + QPainter &p, + const CraftAnimationState &animState, + QPointF center, + float64 size) { + const auto &shared = animState.shared; + + const auto faces = GetVisibleCubeFaces(animState.rotationX, animState.rotationY); + auto painted = false; + + for (const auto faceIndex : faces) { + const auto corners = ComputeCubeFaceCorners( + center, + size, + animState.rotationX, + animState.rotationY, + faceIndex); + if (!corners) { + continue; + } + + const QImage *faceImage = &shared->forgeImage; + auto faceRotation = 0; + + for (auto i = 0; i < 4; ++i) { + if (i != animState.currentlyFlying + && animState.giftToSide[i].face == faceIndex + && shared->corners[i].hasGift + && !shared->corners[i].gift.isNull()) { + faceImage = &shared->corners[i].gift; + faceRotation = animState.giftToSide[i].rotation; + break; + } + } + + if (!faceImage->isNull()) { + PaintCubeFace(p, *faceImage, *corners, faceRotation); + painted = true; + } + } +} + +struct GiftFlightPosition { + QPointF origin; + float64 scale; +}; + +[[nodiscard]] GiftFlightPosition ComputeGiftFlightPosition( + QRect originalRect, + QPointF targetCenter, + float64 targetSize, + float64 progress, + float64 startOffsetY) { + const auto eased = progress; + + const auto startOrigin = QPointF(originalRect.topLeft()) + QPointF(0, startOffsetY); + const auto targetOrigin = targetCenter - QPointF(targetSize, targetSize) / 2.; + const auto currentOrigin = startOrigin + (targetOrigin - startOrigin) * eased; + + const auto originalSize = std::max(originalRect.width(), originalRect.height()); + const auto endScale = (originalSize > 0.) + ? (targetSize / float64(originalSize)) + : 1.; + const auto currentScale = 1. + (endScale - 1.) * eased; + + return { + .origin = currentOrigin, + .scale = currentScale, + }; +} + +void ApplyRotationImpulse(CraftAnimationState &state, int cornerIndex) { + constexpr auto kImpulseStrength = 2.0; + + const auto impulseX = ((cornerIndex < 2) ? 1 : -1) * 0.5; + const auto impulseY = ((cornerIndex % 2) ? 1 : -1) * 1.; + + state.velocityX += impulseX * kImpulseStrength; + state.velocityY += impulseY * kImpulseStrength; + + if (!state.continuousAnimation.animating()) { + state.lastRotationUpdate = 0; + state.continuousAnimation.start(); + } +} + +[[nodiscard]] bool IsCubeNearlyStopped(const CraftAnimationState &state) { + constexpr auto kNearlyStoppedThreshold = 0.05; + return (std::abs(state.velocityX) < kNearlyStoppedThreshold) + && (std::abs(state.velocityY) < kNearlyStoppedThreshold); +} + +[[nodiscard]] FacePlacement GetCameraFacingFreeFace( + const CraftAnimationState &state) { + struct Vec3 { + float64 x, y, z; + }; + + const auto rotateY = [](Vec3 v, float64 angle) { + const auto c = std::cos(angle); + const auto s = std::sin(angle); + return Vec3{ v.x * c + v.z * s, v.y, -v.x * s + v.z * c }; + }; + const auto rotateX = [](Vec3 v, float64 angle) { + const auto c = std::cos(angle); + const auto s = std::sin(angle); + return Vec3{ v.x, v.y * c - v.z * s, v.y * s + v.z * c }; + }; + + constexpr auto normals = std::array{{ + { 0., 0., -1. }, + { 0., 0., 1. }, + { -1., 0., 0. }, + { 1., 0., 0. }, + { 0., -1., 0. }, + { 0., 1., 0. }, + }}; + + constexpr auto faceUpVectors = std::array{{ + { 0., -1., 0. }, + { 0., -1., 0. }, + { 0., -1., 0. }, + { 0., -1., 0. }, + { 0., 0., -1. }, + { 0., 0., 1. }, + }}; + + const auto isOccupied = [&](int faceIndex) { + for (const auto &placement : state.giftToSide) { + if (placement.face == faceIndex) { + return true; + } + } + return false; + }; + + auto bestFace = -1; + auto bestZ = 0.; + + for (auto i = 0; i != 6; ++i) { + if (isOccupied(i)) { + continue; + } + const auto transformed = rotateX( + rotateY(normals[i], state.rotationY), + state.rotationX); + if (bestFace < 0 || transformed.z < bestZ) { + bestFace = i; + bestZ = transformed.z; + } + } + + if (bestFace < 0) { + return {}; + } + + const auto faceUp = rotateX( + rotateY(faceUpVectors[bestFace], state.rotationY), + state.rotationX); + + const auto screenUpY = faceUp.y; + const auto screenUpX = faceUp.x; + + auto rotation = 0; + if (std::abs(screenUpY) >= std::abs(screenUpX)) { + rotation = (screenUpY < 0.) ? 0 : 180; + } else { + rotation = (screenUpX < 0.) ? 90 : 270; + } + + return { .face = bestFace, .rotation = rotation }; +} + +void PaintFlyingGift( + QPainter &p, + const CraftState::CornerSnapshot &corner, + const GiftFlightPosition &position) { + if (corner.gift.isNull()) { + return; + } + + const auto ratio = style::DevicePixelRatio(); + const auto giftSize = QSizeF(corner.gift.size()) / ratio; + const auto scaledSize = giftSize * position.scale; + const auto giftTopLeft = position.origin; + + auto hq = PainterHighQualityEnabler(p); + p.drawImage(QRectF(giftTopLeft, scaledSize), corner.gift); +} + +void PaintSlideOutCorner( + QPainter &p, + const CraftState::CornerSnapshot &corner, + float64 offsetY, + float64 progress) { + if (corner.originalRect.isEmpty()) { + return; + } + + const auto ratio = style::DevicePixelRatio(); + const auto originalTopLeft = QPointF(corner.originalRect.topLeft()); + const auto currentTopLeft = originalTopLeft + QPointF(0, offsetY); + const auto giftSize = QSizeF(corner.gift.size()) / ratio; + const auto giftTopLeft = currentTopLeft; + + const auto &extend = st::defaultDropdownMenu.wrap.shadow.extend; + const auto innerTopLeft = giftTopLeft + QPointF(extend.left(), extend.top()); + const auto innerWidth = giftSize.width() - extend.left() - extend.right(); + + if (corner.hasGift) { + if (!corner.gift.isNull()) { + p.drawImage(giftTopLeft, corner.gift); + } + + const auto badgeFade = std::clamp(1. - progress / 0.7, 0., 1.); + if (!corner.percentBadge.isNull() && badgeFade > 0.) { + const auto badgeSize = QSizeF(corner.percentBadge.size()) / ratio; + const auto badgePos = innerTopLeft - QPointF(badgeSize.width() / 3., badgeSize.height() / 3.); + p.setOpacity(badgeFade); + p.drawImage(badgePos, corner.percentBadge); + p.setOpacity(1.); + } + + if (!corner.removeButton.isNull() && badgeFade > 0.) { + const auto removeSize = QSizeF(corner.removeButton.size()) / ratio; + const auto removePos = innerTopLeft + + QPointF(innerWidth - removeSize.width() + removeSize.width() / 3., -removeSize.height() / 3.); + p.setOpacity(badgeFade); + p.drawImage(removePos, corner.removeButton); + p.setOpacity(1.); + } + } else { + if (!corner.addButton.isNull()) { + const auto fadeProgress = std::clamp(progress * 1.5, 0., 1.); + const auto addTopLeft = currentTopLeft; + p.setOpacity(1. - fadeProgress); + p.drawImage(addTopLeft, corner.addButton); + p.setOpacity(1.); + } + } +} + +void PaintSlideOutPhase( + QPainter &p, + const std::shared_ptr &state, + QSize canvasSize, + float64 progress) { + const auto ratio = style::DevicePixelRatio(); + + if (!state->topPart.isNull()) { + const auto topSize = QSizeF(state->topPart.size()) / ratio; + const auto slideDistance = topSize.height(); + const auto offsetY = slideDistance * progress; + const auto opacity = 1. - progress; + + p.setOpacity(opacity); + p.drawImage(state->topPartRect.topLeft() - QPointF(0, offsetY), state->topPart); + p.setOpacity(1.); + } + + if (!state->bottomPart.isNull()) { + const auto slideDistance = QSizeF(state->bottomPart.size()).height() / ratio; + const auto offsetY = slideDistance * progress; + const auto opacity = 1. - progress; + + p.setOpacity(opacity); + p.drawImage(QPointF(0, state->bottomPartY + offsetY), state->bottomPart); + p.setOpacity(1.); + } + + const auto targetCenterY = state->containerHeight / 2.; + const auto craftingOffsetY = (targetCenterY - state->craftingAreaCenterY) * progress; + + for (const auto &corner : state->corners) { + PaintSlideOutCorner(p, corner, craftingOffsetY, progress); + } + + if (!state->forgeImage.isNull()) { + const auto forgePos = QPointF(state->forgeRect.topLeft()) + QPointF(0, craftingOffsetY); + p.drawImage(forgePos, state->forgeImage); + } +} + +[[nodiscard]] std::pair FindSnapTarget( + const CraftAnimationState &state) { + constexpr auto kPiOver2 = M_PI / 2.; + + const auto isOccupied = [&](int faceIndex) { + for (const auto &placement : state.giftToSide) { + if (placement.face == faceIndex) { + return true; + } + } + return false; + }; + + const auto normalizeAngle = [](float64 angle) { + auto result = angle; + while (result > M_PI) { + result -= 2. * M_PI; + } + while (result <= -M_PI) { + result += 2. * M_PI; + } + return result; + }; + + const auto angleDiff = [&](float64 from, float64 to) { + return normalizeAngle(to - from); + }; + + struct Vec3 { + float64 x, y, z; + }; + + const auto rotateY = [](Vec3 v, float64 angle) { + const auto c = std::cos(angle); + const auto s = std::sin(angle); + return Vec3{ v.x * c + v.z * s, v.y, -v.x * s + v.z * c }; + }; + const auto rotateX = [](Vec3 v, float64 angle) { + const auto c = std::cos(angle); + const auto s = std::sin(angle); + return Vec3{ v.x, v.y * c - v.z * s, v.y * s + v.z * c }; + }; + + constexpr auto normals = std::array{{ + { 0., 0., -1. }, + { 0., 0., 1. }, + { -1., 0., 0. }, + { 1., 0., 0. }, + { 0., -1., 0. }, + { 0., 1., 0. }, + }}; + + const auto getFrontFace = [&](float64 rotX, float64 rotY) { + auto bestFace = 0; + auto bestZ = 0.; + for (auto i = 0; i != 6; ++i) { + const auto transformed = rotateX(rotateY(normals[i], rotY), rotX); + if (i == 0 || transformed.z < bestZ) { + bestFace = i; + bestZ = transformed.z; + } + } + return bestFace; + }; + + auto bestTargetX = 0.; + auto bestTargetY = 0.; + auto bestDistance = std::numeric_limits::max(); + + for (auto ix = 0; ix != 4; ++ix) { + for (auto iy = 0; iy != 4; ++iy) { + const auto targetX = ix * kPiOver2; + const auto targetY = iy * kPiOver2; + + const auto frontFace = getFrontFace(targetX, targetY); + if (isOccupied(frontFace)) { + continue; + } + + const auto diffX = angleDiff(state.rotationX, targetX); + const auto diffY = angleDiff(state.rotationY, targetY); + const auto distance = diffX * diffX + diffY * diffY; + + if (distance < bestDistance) { + bestDistance = distance; + bestTargetX = state.rotationX + diffX; + bestTargetY = state.rotationY + diffY; + } + } + } + + return { bestTargetX, bestTargetY }; +} + +void StartSnapAnimation( + CraftAnimationState *animState, + not_null canvas) { + animState->snapStartX = animState->rotationX; + animState->snapStartY = animState->rotationY; + + const auto [targetX, targetY] = FindSnapTarget(*animState); + animState->snapTargetX = targetX; + animState->snapTargetY = targetY; + + animState->velocityX = 0.; + animState->velocityY = 0.; + animState->snapping = true; + + animState->snapAnimation.start( + [=] { canvas->update(); }, + 0., + 1., + crl::time(300), + anim::easeOutCubic); +} + +void StartGiftFlight( + CraftAnimationState *animState, + not_null canvas, + int startIndex) { + const auto &corners = animState->shared->corners; + + auto nextGift = -1; + for (auto i = startIndex; i < 4; ++i) { + if (corners[i].hasGift) { + nextGift = i; + break; + } + } + + if (nextGift < 0) { + return; + } + + animState->currentlyFlying = nextGift; + animState->nearlyStoppedSince = 0; + + animState->giftToSide[nextGift] = GetCameraFacingFreeFace(*animState); + + animState->flightAnimation.start( + [=] { canvas->update(); }, + 0., + 1., + crl::time(400), + anim::easeInCirc); + + if (!animState->continuousAnimation.animating()) { + animState->lastRotationUpdate = 0; + animState->continuousAnimation.start(); + } +} + +void LandCurrentGift(CraftAnimationState *animState) { + if (animState->currentlyFlying < 0) { + return; + } + + const auto cornerIndex = animState->currentlyFlying; + + ApplyRotationImpulse(*animState, cornerIndex); + + ++animState->giftsLanded; + animState->currentlyFlying = -1; + animState->nearlyStoppedSince = 0; +} + +} // namespace + +void CraftState::paint(QPainter &p, QSize size, int craftingHeight, float64 slideProgress) { + const auto width = size.width(); + const auto getBackdrop = [&](BackdropView &backdrop) { + const auto ratio = style::DevicePixelRatio(); + const auto gradientSize = size; + auto &gradient = backdrop.gradient; + if (gradient.size() != gradientSize * ratio) { + gradient = CreateBgGradient(gradientSize, backdrop.colors); + } + return gradient; + }; + auto patternOffsetY = 0.; + if (slideProgress > 0. && containerHeight > 0 && craftingAreaCenterY > 0) { + patternOffsetY = (containerHeight / 2. - craftingAreaCenterY) * slideProgress; + } + const auto paintPattern = [&]( + QPainter &q, + PatternView &pattern, + const BackdropView &backdrop, + float64 shown) { + const auto color = backdrop.colors.patternColor; + const auto key = (color.red() << 16) + | (color.green() << 8) + | color.blue(); + if (patternOffsetY != 0.) { + q.translate(0, patternOffsetY); + } + PaintBgPoints( + q, + PatternBgPoints(), + pattern.emojis[key], + pattern.emoji.get(), + color, + QRect(0, 0, width, st::boxTitleHeight + craftingHeight), + shown); + if (patternOffsetY != 0.) { + q.translate(0, -patternOffsetY); + } + }; + auto animating = false; + auto newEdgeColor = std::optional(); + auto newButton1 = QColor(); + auto newButton2 = QColor(); + for (auto i = 0; i != 4; ++i) { + auto &cover = covers[i]; + if (cover.shownAnimation.animating()) { + animating = true; + } + const auto finalValue = cover.shown ? 1. : 0.; + const auto shown = cover.shownAnimation.value(finalValue); + if (shown <= 0.) { + break; + } else if (shown == 1.) { + const auto next = i + 1; + if (next < 4 + && covers[next].shown + && !covers[next].shownAnimation.animating()) { + continue; + } + } + p.setOpacity(shown); + p.drawImage(0, 0, getBackdrop(cover.backdrop)); + paintPattern(p, cover.pattern, cover.backdrop, 1.); + if (coversAnimate) { + const auto edge = cover.backdrop.colors.edgeColor; + if (!newEdgeColor) { + newEdgeColor = edge; + newButton1 = cover.button1; + newButton2 = cover.button2; + } else { + newEdgeColor = anim::color(*newEdgeColor, edge, shown); + newButton1 = anim::color(newButton1, cover.button1, shown); + newButton2 = anim::color(newButton2, cover.button2, shown); + } + } + } + if (newEdgeColor) { + edgeColor = *newEdgeColor; + button1 = newButton1; + button2 = newButton2; + } + if (!animating) { + coversAnimate = false; + } +} + +void CraftState::updateForGiftCount(int count) { + for (auto i = 4; i != 0;) { + auto &cover = covers[--i]; + const auto shown = (i < count); + if (cover.shown != shown) { + cover.shown = shown; + const auto from = shown ? 0. : 1.; + const auto to = shown ? 1. : 0.; + cover.shownAnimation.start([this] { + if (repaint) { + repaint(); + } + }, from, to, st::fadeWrapDuration); + coversAnimate = true; + } + } +} + +void StartCraftAnimation( + not_null container, + std::shared_ptr state) { + while (container->count() > 0) { + delete container->widgetAt(0); + } + + const auto height = state->containerHeight; + const auto craftingHeight = state->craftingBottom - state->craftingTop; + + auto canvas = object_ptr(container); + const auto raw = canvas.data(); + raw->resize(container->width(), height); + + const auto animState = raw->lifetime().make_state(); + animState->shared = std::move(state); + animState->totalGifts = ranges::count( + animState->shared->corners, + true, + &CraftState::CornerSnapshot::hasGift); + + raw->paintOn([=](QPainter &p) { + const auto shared = animState->shared; + const auto slideProgress = animState->slideOutAnimation.value(1.); + shared->paint(p, raw->size(), craftingHeight, slideProgress); + + const auto craftingOffsetY = (shared->containerHeight / 2.) + - shared->craftingAreaCenterY; + const auto cubeSize = float64(shared->forgeRect.width()); + const auto cubeCenter = QPointF(shared->forgeRect.topLeft()) + + QPointF(cubeSize, cubeSize) / 2. + + QPointF(0, craftingOffsetY); + + if (slideProgress < 1.) { + PaintSlideOutPhase(p, shared, raw->size(), slideProgress); + } else { + for (auto i = 0; i < 4; ++i) { + const auto &corner = shared->corners[i]; + if (corner.hasGift && animState->giftToSide[i].face < 0) { + const auto giftTopLeft = QPointF(corner.originalRect.topLeft()) + + QPointF(0, craftingOffsetY); + p.drawImage(giftTopLeft, corner.gift); + } + } + + PaintCube(p, *animState, cubeCenter, cubeSize); + + if (animState->flightAnimation.animating()) { + const auto flying = animState->currentlyFlying; + if (flying >= 0 && flying < 4) { + const auto &corner = shared->corners[flying]; + const auto progress = animState->flightAnimation.value(1.); + const auto position = ComputeGiftFlightPosition( + corner.originalRect, + cubeCenter, + cubeSize, + progress, + craftingOffsetY); + PaintFlyingGift(p, corner, position); + } + } + } + }); + + animState->slideOutAnimation.start( + [=] { + raw->update(); + + const auto progress = animState->slideOutAnimation.value(1.); + if (progress >= 1. + && animState->currentlyFlying < 0 + && animState->giftsLanded == 0) { + StartGiftFlight(animState, raw, 0); + } + }, + 0., + 1., + crl::time(300), + anim::easeOutCubic); + + animState->continuousAnimation.init([=](crl::time now) { + if (animState->lastRotationUpdate == 0) { + animState->lastRotationUpdate = now; + return true; + } + + const auto dt = float64(now - animState->lastRotationUpdate); + animState->lastRotationUpdate = now; + + constexpr auto kFrameDuration = 1000. / 60.; + constexpr auto kVelocityDecay = 0.99; + constexpr auto kDelayBeforeNextFlight = crl::time(100); + + if (animState->snapping) { + const auto progress = animState->snapAnimation.value(1.); + animState->rotationX = animState->snapStartX + + (animState->snapTargetX - animState->snapStartX) * progress; + animState->rotationY = animState->snapStartY + + (animState->snapTargetY - animState->snapStartY) * progress; + + if (progress >= 1.) { + animState->snapping = false; + animState->rotationX = animState->snapTargetX; + animState->rotationY = animState->snapTargetY; + } + } else if (animState->giftsLanded > 0 + || animState->flightAnimation.animating()) { + animState->rotationX += animState->velocityX * dt / 1000.; + animState->rotationY += animState->velocityY * dt / 1000.; + + const auto decayFactor = std::pow(kVelocityDecay, dt / kFrameDuration); + animState->velocityX *= decayFactor; + animState->velocityY *= decayFactor; + } + + raw->update(); + + if (animState->currentlyFlying >= 0 + && !animState->flightAnimation.animating()) { + LandCurrentGift(animState); + } + + if (!animState->flightAnimation.animating() + && animState->currentlyFlying < 0 + && animState->giftsLanded < animState->totalGifts) { + + if (IsCubeNearlyStopped(*animState)) { + if (animState->nearlyStoppedSince == 0) { + animState->nearlyStoppedSince = now; + } else if (now - animState->nearlyStoppedSince >= kDelayBeforeNextFlight) { + auto nextIndex = 0; + for (auto i = 0; i < 4; ++i) { + if (animState->shared->corners[i].hasGift + && animState->giftToSide[i].face < 0) { + nextIndex = i; + break; + } + } + StartGiftFlight(animState, raw, nextIndex); + } + } else { + animState->nearlyStoppedSince = 0; + } + } + + if (!animState->snapping + && !animState->flightAnimation.animating() + && animState->currentlyFlying < 0 + && animState->giftsLanded >= animState->totalGifts + && animState->totalGifts > 0) { + + if (IsCubeNearlyStopped(*animState)) { + if (animState->nearlyStoppedSince == 0) { + animState->nearlyStoppedSince = now; + } else if (now - animState->nearlyStoppedSince >= kDelayBeforeNextFlight) { + StartSnapAnimation(animState, raw); + } + } else { + animState->nearlyStoppedSince = 0; + } + } + + const auto hasVelocity = (std::abs(animState->velocityX) > 0.001) + || (std::abs(animState->velocityY) > 0.001); + const auto hasMoreFlights = (animState->giftsLanded < animState->totalGifts); + const auto isFlying = animState->flightAnimation.animating(); + const auto isSnapping = animState->snapping; + + return hasVelocity || hasMoreFlights || isFlying || isSnapping; + }); + + container->add(std::move(canvas)); +} + +} // namespace Ui diff --git a/Telegram/SourceFiles/boxes/star_gift_craft_animation.h b/Telegram/SourceFiles/boxes/star_gift_craft_animation.h new file mode 100644 index 0000000000..3ca53b7a1f --- /dev/null +++ b/Telegram/SourceFiles/boxes/star_gift_craft_animation.h @@ -0,0 +1,114 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "data/data_star_gift.h" +#include "ui/effects/animations.h" +#include "ui/text/text_custom_emoji.h" + +namespace Ui { + +class VerticalLayout; + +struct BackdropView { + Data::UniqueGiftBackdrop colors; + QImage gradient; + +}; + +struct PatternView { + std::unique_ptr emoji; + base::flat_map> emojis; + +}; + +struct CraftState { + struct Cover { + BackdropView backdrop; + PatternView pattern; + QColor button1; + QColor button2; + Animations::Simple shownAnimation; + bool shown = false; + }; + + std::array covers; + rpl::variable edgeColor; + QColor button1; + QColor button2; + bool coversAnimate = false; + Fn repaint; + + QImage topPart; + QRect topPartRect; + QImage bottomPart; + int bottomPartY = 0; + + struct CornerSnapshot { + QImage gift; + QImage percentBadge; + QImage removeButton; + QImage addButton; + QRect originalRect; + bool hasGift = false; + }; + std::array corners; + + QImage forgeImage; + QRect forgeRect; + + int containerHeight = 0; + int craftingTop = 0; + int craftingBottom = 0; + int craftingAreaCenterY = 0; + + void paint(QPainter &p, QSize size, int craftingHeight, float64 slideProgress = 0.); + void updateForGiftCount(int count); + +}; + +struct FacePlacement { + int face = -1; + int rotation = 0; + +}; + +struct CraftAnimationState { + std::shared_ptr shared; + + Animations::Simple slideOutAnimation; + Animations::Basic continuousAnimation; + + float64 rotationX = 0.; + float64 rotationY = 0.; + float64 velocityX = 0.; + float64 velocityY = 0.; + crl::time lastRotationUpdate = 0; + + int currentlyFlying = -1; + int giftsLanded = 0; + int totalGifts = 0; + std::array giftToSide; + Animations::Simple flightAnimation; + + bool snapping = false; + Animations::Simple snapAnimation; + float64 snapTargetX = 0.; + float64 snapTargetY = 0.; + float64 snapStartX = 0.; + float64 snapStartY = 0.; + + crl::time nearlyStoppedSince = 0; + +}; + +void StartCraftAnimation( + not_null container, + std::shared_ptr state); + +} // namespace Ui diff --git a/Telegram/SourceFiles/boxes/star_gift_craft_box.cpp b/Telegram/SourceFiles/boxes/star_gift_craft_box.cpp index b6ad42c7c4..ee363d0251 100644 --- a/Telegram/SourceFiles/boxes/star_gift_craft_box.cpp +++ b/Telegram/SourceFiles/boxes/star_gift_craft_box.cpp @@ -7,6 +7,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "boxes/star_gift_craft_box.h" +#include "boxes/star_gift_craft_animation.h" #include "apiwrap.h" #include "api/api_credits.h" #include "api/api_premium.h" @@ -30,6 +31,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/gradient_round_button.h" #include "ui/painter.h" #include "ui/top_background_gradient.h" +#include "ui/ui_utility.h" #include "ui/vertical_list.h" #include "window/window_session_controller.h" #include "styles/style_chat_helpers.h" // stickerPanDeleteIconFg @@ -95,6 +97,7 @@ struct CraftingView { object_ptr widget; rpl::producer editRequests; rpl::producer removeRequests; + Fn)> grabForAnimation; }; [[nodiscard]] QString FormatPercent(int permille) { @@ -456,38 +459,41 @@ AbstractButton *MakeRemoveButton( st::craftForgePadding, })); + auto grabForAnimation = [=](std::shared_ptr craftState) { + craftState->forgeRect = center; + + craftState->forgeImage = GrabWidgetToImage(raw, center); + + for (auto i = 0; i != 4; ++i) { + const auto &entry = state->entries[i]; + auto &corner = craftState->corners[i]; + + if (entry.button) { + corner.hasGift = true; + corner.gift = GrabWidgetToImage(entry.button); + corner.originalRect = entry.button->geometry(); + if (entry.percent) { + corner.percentBadge = GrabWidgetToImage(entry.percent); + } + if (entry.remove) { + corner.removeButton = GrabWidgetToImage(entry.remove); + } + } else if (entry.add) { + corner.hasGift = false; + corner.addButton = GrabWidgetToImage(entry.add); + corner.originalRect = entry.add->geometry(); + } + } + }; + return { .widget = std::move(widget), .editRequests = state->editRequests.events(), .removeRequests = state->removeRequests.events(), + .grabForAnimation = std::move(grabForAnimation), }; } -[[nodiscard]] QImage CreateBgGradient( - QSize size, - const Data::UniqueGiftBackdrop &backdrop) { - const auto ratio = style::DevicePixelRatio(); - auto result = QImage(size * ratio, QImage::Format_ARGB32_Premultiplied); - result.setDevicePixelRatio(ratio); - - auto p = QPainter(&result); - auto hq = PainterHighQualityEnabler(p); - auto gradient = QRadialGradient( - QPoint(size.width() / 2, size.height() / 2), - size.height() / 2); - gradient.setStops({ - { 0., backdrop.centerColor }, - { 1., backdrop.edgeColor }, - }); - p.setBrush(gradient); - p.setPen(Qt::NoPen); - p.drawRect(0, 0, size.width(), size.height()); - p.end(); - - const auto mask = Images::CornersMask(st::boxRadius); - return Images::Round(std::move(result), mask); -} - void ShowSelectGiftBox( not_null controller, std::vector list, @@ -775,59 +781,29 @@ void ShowSelectGiftBox( } void Craft( - not_null controller, - std::vector gifts) { -#if 0 - auto inputs = QVector(); - for (const auto &gift : gifts) { - inputs.push_back( - Api::InputSavedStarGiftId(gift.manageId, gift.unique)); - } - const auto weak = base::make_weak(controller); - const auto session = &controller->session(); - session->api().request(MTPpayments_CraftStarGift( - MTP_vector(inputs) - )).done([=](const MTPUpdates &result) { - session->api().applyUpdates(result); - if (const auto strong = weak.get()) { - strong->showPeerHistory(strong->session().user()->id); - } - }).send(); -#endif + not_null container, + std::shared_ptr state) { + StartCraftAnimation(container, std::move(state)); } void MakeCraftContent( not_null box, not_null controller, GiftForCraft gift) { - struct BackdropView { - Data::UniqueGiftBackdrop colors; - QImage gradient; - }; - struct PatternView { - std::unique_ptr emoji; - base::flat_map> emojis; - }; - struct Cover { - BackdropView backdrop; - PatternView pattern; - QColor button1; - QColor button2; - Ui::Animations::Simple shownAnimation; - bool shown = false; - }; struct State { explicit State(not_null session) : delegate(session, GiftButtonMode::CraftPreview) { } Delegate delegate; - std::array covers; - rpl::variable coverEdgeColor; - QColor coverButton1; - QColor coverButton2; + std::shared_ptr craftState; GradientButton *button = nullptr; - bool coversAnimate = false; + + FlatLabel *title = nullptr; + FlatLabel *about = nullptr; + RpWidget *attributes = nullptr; + RpWidget *craftingView = nullptr; + Fn)> grabCraftingView; rpl::variable> chosen; rpl::variable name; @@ -839,22 +815,25 @@ void MakeCraftContent( }; const auto session = &controller->session(); const auto state = box->lifetime().make_state(session); - state->coversAnimate = true; + + state->craftState = std::make_shared(); + state->craftState->coversAnimate = true; { auto backdrops = CraftBackdrops(); const auto emoji = Text::IconEmoji(&st::craftPattern); const auto data = emoji.entities.front().data(); for (auto i = 0; i != backdrops.size(); ++i) { - state->covers[i].backdrop.colors = backdrops[i].backdrop; - state->covers[i].pattern.emoji = Text::TryMakeSimpleEmoji(data); - state->covers[i].button1 = backdrops[i].button1; - state->covers[i].button2 = backdrops[i].button2; + auto &cover = state->craftState->covers[i]; + cover.backdrop.colors = backdrops[i].backdrop; + cover.pattern.emoji = Text::TryMakeSimpleEmoji(data); + cover.button1 = backdrops[i].button1; + cover.button2 = backdrops[i].button2; } } state->chosen = std::vector{ gift }; - state->covers[0].shown = true; + state->craftState->covers[0].shown = true; state->name = state->chosen.value( ) | rpl::map([=](const std::vector &gifts) { @@ -875,25 +854,13 @@ void MakeCraftContent( const auto raw = box->verticalLayout(); + state->craftState->repaint = [=] { raw->update(); }; state->chosen.value( ) | rpl::on_next([=](const std::vector &gifts) { - const auto count = int(gifts.size()); - for (auto i = 4; i != 0;) { - auto &cover = state->covers[--i]; - const auto shown = (i < count); - if (cover.shown != shown) { - cover.shown = shown; - const auto from = shown ? 0. : 1.; - const auto to = shown ? 1. : 0.; - cover.shownAnimation.start([=] { - raw->update(); - }, from, to, st::fadeWrapDuration); - state->coversAnimate = true; - } - } + state->craftState->updateForGiftCount(int(gifts.size())); }, box->lifetime()); - const auto title = raw->add( + const auto title = state->title = raw->add( object_ptr( box, tr::lng_gift_craft_title(), @@ -906,8 +873,10 @@ void MakeCraftContent( raw, session, state->chosen.value(), - state->coverEdgeColor.value()); + state->craftState->edgeColor.value()); const auto craftingHeight = crafting.widget->height(); + state->craftingView = crafting.widget.data(); + state->grabCraftingView = std::move(crafting.grabForAnimation); raw->add(std::move(crafting.widget)); std::move(crafting.removeRequests) | rpl::on_next([=](int index) { auto chosen = state->chosen.current(); @@ -974,7 +943,7 @@ void MakeCraftContent( ) | rpl::map([=](TextWithEntities &&a, TextWithEntities &&b) { return a.append('\n').append('\n').append(b); }); - const auto about = raw->add( + const auto about = state->about = raw->add( object_ptr( raw, std::move(aboutText), @@ -986,84 +955,20 @@ void MakeCraftContent( about->setTextColorOverride(st::white->c); about->setTryMakeSimilarLines(true); - raw->add( + state->attributes = raw->add( MakeRarityExpectancyPreview(raw, session, state->chosen.value()), style::al_top); raw->paintOn([=](QPainter &p) { - const auto width = raw->width(); - const auto getBackdrop = [&](BackdropView &backdrop) { - const auto ratio = style::DevicePixelRatio(); - const auto gradientSize = QSize(width, raw->height()); - auto &gradient = backdrop.gradient; - if (gradient.size() != gradientSize * ratio) { - gradient = CreateBgGradient(gradientSize, backdrop.colors); - } - return gradient; - }; - const auto paintPattern = [&]( - QPainter &p, - PatternView &pattern, - const BackdropView &backdrop, - float64 shown) { - const auto color = backdrop.colors.patternColor; - const auto key = (color.red() << 16) - | (color.green() << 8) - | color.blue(); - PaintBgPoints( - p, - PatternBgPoints(), - pattern.emojis[key], - pattern.emoji.get(), - color, - QRect(0, 0, width, st::boxTitleHeight + craftingHeight), - shown); - }; - auto animating = false; - auto edgeColor = std::optional(); - auto button1 = QColor(); - auto button2 = QColor(); - for (auto i = 0; i != 4; ++i) { - auto &cover = state->covers[i]; - if (cover.shownAnimation.animating()) { - animating = true; - } - const auto finalValue = cover.shown ? 1. : 0.; - const auto shown = cover.shownAnimation.value(finalValue); - if (shown <= 0.) { - break; - } else if (shown == 1.) { - const auto next = i + 1; - if (next < 4 - && state->covers[next].shown - && !state->covers[next].shownAnimation.animating()) { - continue; - } - } - p.setOpacity(shown); - p.drawImage(0, 0, getBackdrop(cover.backdrop)); - paintPattern(p, cover.pattern, cover.backdrop, 1.); - if (state->coversAnimate) { - const auto edge = cover.backdrop.colors.edgeColor; - if (!edgeColor) { - edgeColor = edge; - button1 = cover.button1; - button2 = cover.button2; - } else { - edgeColor = anim::color(*edgeColor, edge, shown); - button1 = anim::color(button1, cover.button1, shown); - button2 = anim::color(button2, cover.button2, shown); - } - } - } - if (edgeColor) { - state->coverEdgeColor = *edgeColor; - state->coverButton1 = button1; - state->coverButton2 = button2; - state->button->setStops({ { 0., button1 }, { 1., button2 } }); - } - if (!animating) { - state->coversAnimate = false; + const auto &cs = state->craftState; + const auto wasButton1 = cs->button1; + const auto wasButton2 = cs->button2; + cs->paint(p, raw->size(), craftingHeight); + if (cs->button1 != wasButton1 || cs->button2 != wasButton2) { + state->button->setStops({ + { 0., cs->button1 }, + { 1., cs->button2 }, + }); } }); @@ -1078,7 +983,55 @@ void MakeCraftContent( return; } state->crafting = true; - Craft(controller, state->chosen.current()); + + const auto &cs = state->craftState; + + cs->topPart = GrabWidgetToImage(state->title); + cs->topPartRect = state->title->geometry(); + + if (state->grabCraftingView) { + state->grabCraftingView(cs); + } + + const auto craftingPos = state->craftingView->pos(); + cs->craftingTop = craftingPos.y(); + cs->craftingBottom = craftingPos.y() + state->craftingView->height(); + + for (auto &corner : cs->corners) { + corner.originalRect.translate(craftingPos); + } + cs->forgeRect.translate(craftingPos); + cs->craftingAreaCenterY = cs->forgeRect.center().y(); + + const auto aboutPos = state->about->pos(); + const auto buttonBottom = state->button->pos().y() + + state->button->height(); + const auto bottomRect = QRect( + 0, + aboutPos.y(), + raw->width(), + buttonBottom - aboutPos.y()); + + const auto ratio = style::DevicePixelRatio(); + auto bottomPart = QImage( + bottomRect.size() * ratio, + QImage::Format_ARGB32_Premultiplied); + bottomPart.fill(Qt::transparent); + bottomPart.setDevicePixelRatio(ratio); + + const auto renderWidget = [&](QWidget *widget) { + const auto pos = widget->pos() - bottomRect.topLeft(); + widget->render(&bottomPart, pos, QRegion(), QWidget::DrawChildren); + }; + renderWidget(state->about); + renderWidget(state->attributes); + renderWidget(state->button); + + cs->bottomPart = std::move(bottomPart); + cs->bottomPartY = aboutPos.y(); + cs->containerHeight = raw->height(); + + Craft(raw, state->craftState); }); SetButtonTwoLabels( @@ -1130,9 +1083,6 @@ void ShowGiftCraftInfoBox( return result; }); AddUniqueGiftCover(container, std::move(cover), { - .pretitle = tr::lng_gift_craft_info_level( - lt_n, - rpl::single(QString::number(2))), .subtitle = tr::lng_gift_craft_info_about(tr::marked), }); AddSkip(container); diff --git a/Telegram/SourceFiles/window/window_session_controller.cpp b/Telegram/SourceFiles/window/window_session_controller.cpp index 68c3a803e5..a7f0f2561e 100644 --- a/Telegram/SourceFiles/window/window_session_controller.cpp +++ b/Telegram/SourceFiles/window/window_session_controller.cpp @@ -100,6 +100,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_chat_invite.h" #include "api/api_global_privacy.h" #include "api/api_blocked_peers.h" +#include "api/api_premium.h" +#include "boxes/star_gift_craft_box.h" #include "support/support_helper.h" #include "storage/file_upload.h" #include "storage/download_manager_mtproto.h" @@ -1679,6 +1681,34 @@ SessionController::SessionController( activateFirstChatsFilter(); setupPremiumToast(); }); + +#if 1 // TEST: Auto-open craft box on startup + crl::on_main(this, [=] { + const auto user = session->user(); + session->api().request(MTPpayments_GetSavedStarGifts( + MTP_flags(0), + user->input(), + MTP_int(0), + MTP_string(QString()), + MTP_int(50) + )).done([=](const MTPpayments_SavedStarGifts &result) { + const auto &data = result.data(); + session->data().processUsers(data.vusers()); + session->data().processChats(data.vchats()); + for (const auto &gift : data.vgifts().v) { + if (auto parsed = Api::FromTL(user, gift)) { + const auto unique = parsed->info.unique; + if (unique + && unique->craftChancePermille > 0 + && !unique->canCraftAt) { + Ui::ShowGiftCraftInfoBox(this, unique, parsed->manageId); + return; + } + } + } + }).send(); + }); +#endif } bool SessionController::skipNonPremiumLimitToast(bool download) const {