feat: achievement notification custom position and animations

This commit is contained in:
Zamitto
2025-05-15 19:42:23 -03:00
parent 42e8a68c08
commit 6f43da8d28
13 changed files with 502 additions and 152 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

View File

@@ -35,6 +35,7 @@ import type {
CatalogueSearchResult,
ShopAssets,
ShopDetailsWithAssets,
AchievementCustomNotificationPosition,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -326,14 +327,27 @@ declare global {
cb: (
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
position: AchievementCustomNotificationPosition,
achievements?: {
displayName: string;
iconUrl: string;
isHidden: boolean;
isRare: boolean;
isPlatinum: boolean;
}[]
) => void
) => () => Electron.IpcRenderer;
onCombinedAchievementsUnlocked: (
cb: (gameCount: number, achievementCount: number) => void
cb: (
gameCount: number,
achievementCount: number,
position: AchievementCustomNotificationPosition
) => void
) => () => Electron.IpcRenderer;
onTestAchievementNotification: (cb: () => void) => Electron.IpcRenderer;
updateAchievementCustomNotificationWindowPosition: () => Promise<void>;
onTestAchievementNotification: (
cb: (position: AchievementCustomNotificationPosition) => void
) => Electron.IpcRenderer;
updateAchievementCustomNotificationWindow: () => Promise<void>;
/* Themes */
addCustomTheme: (theme: Theme) => Promise<void>;

View File

@@ -1,35 +1,221 @@
@use "../../../scss/globals.scss";
@keyframes achievement-in {
$margin-horizontal: 40px;
$margin-vertical: 52px;
@keyframes content-in {
0% {
transform: translateY(-240px);
width: 80px;
opacity: 0;
transform: scale(0);
}
100% {
transform: translateY(0);
width: 80px;
opacity: 1;
transform: scale(1);
}
}
@keyframes achievement-out {
@keyframes content-wait {
0% {
transform: translateY(0);
width: 80px;
}
100% {
transform: translateY(-240px);
width: 80px;
}
}
@keyframes trophy-out {
0% {
opacity: 1;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes ellipses-stand-by {
0% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ellipses-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
scale: 1.5;
}
}
@keyframes content-expand {
0% {
width: 80px;
}
100% {
width: calc(360px - $margin-horizontal);
}
}
@keyframes title-in {
0% {
transform: translateY(10px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes description-in {
0% {
transform: translateY(20px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes dark-overlay {
0% {
opacity: 0.7;
}
50% {
opacity: 0.7;
}
100% {
opacity: 0;
}
}
@keyframes content-out {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(-20px);
opacity: 0;
}
}
.achievement-notification {
margin-top: 24px;
margin-left: 24px;
animation-duration: 1s;
height: 60px;
display: flex;
animation-name: achievement-in;
transform: translateY(0);
position: relative;
display: grid;
width: calc(360px - $margin-horizontal);
height: 140px;
overflow: hidden;
animation:
content-in 450ms ease-in-out,
content-wait 450ms ease-in-out 450ms,
content-expand 450ms ease-in-out 900ms;
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
width: 64px;
height: 64px;
opacity: 0;
z-index: 1;
background: url("/src/assets/icons/ellipses.png");
background-size: contain;
animation: ellipses-out 900ms ease-in-out;
}
&::after {
content: "";
position: absolute;
top: 0px;
width: 80px;
height: 80px;
opacity: 0;
background: url("/src/assets/icons/trophy.png") no-repeat center;
animation: trophy-out 900ms ease-in-out;
}
&.top_left {
margin: $margin-vertical 0 0 $margin-horizontal;
}
&.top_center {
margin: $margin-vertical 0 0 $margin-horizontal;
}
&.top_right {
margin: $margin-vertical $margin-horizontal 0 0;
align-self: end;
}
&.bottom_left {
margin: 0 0 $margin-vertical $margin-horizontal;
}
&.bottom_center {
margin: 0 0 $margin-vertical $margin-horizontal;
}
&.bottom_right {
margin: 0 $margin-horizontal $margin-vertical 0;
align-self: end;
}
&.closing {
animation-name: achievement-out;
transform: translateY(-240px);
transform: translateY(-20px);
opacity: 0;
animation: content-out 450ms ease-in-out;
}
&__container {
width: calc(360px - $margin-horizontal);
max-width: 100%;
border: 1px solid #ffffff1a;
display: flex;
padding: 8px 16px 8px 8px;
background: globals.$background-color;
transform: translateY(0);
box-shadow: 0px 2px 16px 0px rgba(0, 0, 0, 0.25);
&.top_left {
align-self: flex-start;
justify-self: flex-start;
}
&.top_center {
align-self: flex-start;
justify-self: center;
}
&.top_right {
align-self: flex-start;
justify-self: flex-end;
}
&.bottom_left {
align-self: flex-end;
justify-self: flex-start;
}
&.bottom_center {
align-self: flex-end;
justify-self: center;
}
&.bottom_right {
align-self: flex-end;
justify-self: flex-end;
}
}
&__content {
@@ -37,7 +223,51 @@
flex-direction: row;
gap: 8px;
align-items: center;
background: globals.$background-color;
padding-right: 8px;
width: 100%;
overflow: hidden;
position: relative;
&::after {
content: "";
position: absolute;
width: 64px;
height: 64px;
background: #000;
opacity: 0;
animation: dark-overlay 900ms ease-in-out;
}
}
&__icon {
min-width: 64px;
min-height: 64px;
border-radius: 2px;
flex: 1;
position: relative;
}
&__text-container {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
&__title {
font-size: 14px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
color: globals.$muted-color;
animation: title-in 450ms ease-in-out 900ms;
}
&__description {
font-size: 14px;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
color: globals.$body-color;
animation: description-in 450ms ease-in-out 900ms;
}
}

View File

@@ -3,6 +3,7 @@ import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
import cn from "classnames";
import "./achievement-notification.scss";
import { AchievementCustomNotificationPosition } from "@types";
interface AchievementInfo {
displayName: string;
@@ -16,6 +17,8 @@ export function AchievementNotification() {
const [isClosing, setIsClosing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] =
useState<AchievementCustomNotificationPosition>("top_left");
const [achievements, setAchievements] = useState<AchievementInfo[]>([]);
const [currentAchievement, setCurrentAchievement] =
@@ -33,9 +36,11 @@ export function AchievementNotification() {
useEffect(() => {
const unsubscribe = window.electron.onCombinedAchievementsUnlocked(
(gameCount, achievementCount) => {
(gameCount, achievementCount, position) => {
if (gameCount === 0 || achievementCount === 0) return;
setPosition(position);
setAchievements([
{
displayName: t("new_achievements_unlocked", {
@@ -58,9 +63,10 @@ export function AchievementNotification() {
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(
(_object, _shop, achievements) => {
(_object, _shop, position, achievements) => {
if (!achievements?.length) return;
setPosition(position);
setAchievements((ach) => ach.concat(achievements));
playAudio();
@@ -73,19 +79,22 @@ export function AchievementNotification() {
}, [playAudio]);
useEffect(() => {
const unsubscribe = window.electron.onTestAchievementNotification(() => {
setAchievements((ach) =>
ach.concat([
{
displayName: "Test Achievement",
iconUrl:
"https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fc.tenor.com%2FRwKr7hVnXREAAAAC%2Fnyan-cat.gif&f=1&nofb=1&ipt=706fd8b00cbfb5b2d2621603834d5f32c0f34cce7113de228d2fcc2247a80318",
},
])
);
const unsubscribe = window.electron.onTestAchievementNotification(
(position) => {
setPosition(position);
setAchievements((ach) =>
ach.concat([
{
displayName: "Test Achievement",
iconUrl:
"https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fc.tenor.com%2FRwKr7hVnXREAAAAC%2Fnyan-cat.gif&f=1&nofb=1&ipt=706fd8b00cbfb5b2d2621603834d5f32c0f34cce7113de228d2fcc2247a80318",
},
])
);
playAudio();
});
playAudio();
}
);
return () => {
unsubscribe();
@@ -104,7 +113,7 @@ export function AchievementNotification() {
const zero = performance.now();
closingAnimation.current = requestAnimationFrame(
function animateClosing(time) {
if (time - zero <= 1000) {
if (time - zero <= 450) {
closingAnimation.current = requestAnimationFrame(animateClosing);
} else {
setIsVisible(false);
@@ -147,18 +156,30 @@ export function AchievementNotification() {
return (
<div
className={cn("achievement-notification", {
[position]: true,
closing: isClosing,
})}
>
<div className="achievement-notification__content">
<img
src={currentAchievement.iconUrl}
alt={currentAchievement.displayName}
style={{ flex: 1, width: "60px" }}
/>
<div>
<p>{t("achievement_unlocked")}</p>
<p>{currentAchievement.displayName}</p>
<div
className={cn("achievement-notification__container", {
closing: isClosing,
[position]: true,
})}
>
<div className="achievement-notification__content">
<img
src={currentAchievement.iconUrl}
alt={currentAchievement.displayName}
className="achievement-notification__icon"
/>
<div className="achievement-notification__text-container">
<p className="achievement-notification__title">
{t("achievement_unlocked")}
</p>
<p className="achievement-notification__description">
{currentAchievement.displayName}
</p>
</div>
</div>
</div>
</div>

View File

@@ -119,10 +119,10 @@ export function SettingsGeneral() {
const achievementCustomNotificationPositionOptions = useMemo(() => {
return [
"top_left",
"top_center",
"top_right",
"bottom_left",
"bottom_right",
"top_center",
"bottom_center",
].map((position) => ({
key: position,
@@ -152,7 +152,7 @@ export function SettingsGeneral() {
await handleChange({ achievementCustomNotificationPosition: value });
window.electron.updateAchievementCustomNotificationWindowPosition();
window.electron.updateAchievementCustomNotificationWindow();
};
const handleChooseDownloadsPath = async () => {
@@ -251,24 +251,28 @@ export function SettingsGeneral() {
<CheckboxField
label={t("enable_achievement_notifications")}
checked={form.achievementNotificationsEnabled}
onChange={() =>
handleChange({
onChange={async () => {
await handleChange({
achievementNotificationsEnabled:
!form.achievementNotificationsEnabled,
})
}
});
window.electron.updateAchievementCustomNotificationWindow();
}}
/>
<CheckboxField
label={t("enable_achievement_custom_notifications")}
checked={form.achievementCustomNotificationsEnabled}
disabled={!form.achievementNotificationsEnabled}
onChange={() =>
handleChange({
onChange={async () => {
await handleChange({
achievementCustomNotificationsEnabled:
!form.achievementCustomNotificationsEnabled,
})
}
});
window.electron.updateAchievementCustomNotificationWindow();
}}
/>
{form.achievementNotificationsEnabled &&