feat(achievements): implement achievements screen
This commit is contained in:
91
app/components/Achievements/BottomScreen.vue
Normal file
91
app/components/Achievements/BottomScreen.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { onRender, onClick } = useScreen();
|
||||||
|
const store = useAchievementsScreen();
|
||||||
|
const achievementsStore = useAchievementsStore();
|
||||||
|
const { assets } = useAssets();
|
||||||
|
|
||||||
|
const QUIT_SIZE = assets.images.achievements.quit.rect.width;
|
||||||
|
const QUIT_X = Math.floor(LOGICAL_WIDTH / 2 - QUIT_SIZE / 2);
|
||||||
|
const QUIT_Y = 135;
|
||||||
|
|
||||||
|
useKeyDown((key) => {
|
||||||
|
if (store.isIntro || store.isOutro) return;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "NDS_B":
|
||||||
|
case "NDS_A":
|
||||||
|
case "NDS_START": {
|
||||||
|
store.animateOutro();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onClick((x, y) => {
|
||||||
|
if (store.isIntro || store.isOutro) return;
|
||||||
|
|
||||||
|
if (rectContains([QUIT_X, QUIT_Y, QUIT_SIZE, QUIT_SIZE], [x, y])) {
|
||||||
|
store.animateOutro();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onRender((ctx) => {
|
||||||
|
assets.images.home.bottomScreen.background.draw(ctx, 0, 0);
|
||||||
|
|
||||||
|
ctx.globalAlpha = store.isIntro
|
||||||
|
? store.intro.stage1Opacity
|
||||||
|
: store.isOutro
|
||||||
|
? store.outro.stage1Opacity
|
||||||
|
: 1;
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.fillRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);
|
||||||
|
|
||||||
|
// achievement list (reversed iteration because they appear in cascade)
|
||||||
|
ctx.globalAlpha = store.isIntro
|
||||||
|
? store.intro.stage2Opacity
|
||||||
|
: store.isOutro
|
||||||
|
? store.outro.stage2Opacity
|
||||||
|
: 1;
|
||||||
|
ctx.font = "7px NDS7";
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
for (
|
||||||
|
let i = ACHIEVEMENTS.length - 1;
|
||||||
|
i >= ACHIEVEMENTS_TOP_SCREEN_COUNT;
|
||||||
|
i--
|
||||||
|
) {
|
||||||
|
const achievement = ACHIEVEMENTS[i]!;
|
||||||
|
const isUnlocked = achievementsStore.isUnlocked(achievement);
|
||||||
|
|
||||||
|
const offset = store.getItemOffset(i);
|
||||||
|
if (offset === -ACHIEVEMENTS_LINE_HEIGHT) continue;
|
||||||
|
|
||||||
|
const baseY =
|
||||||
|
ACHIEVEMENTS_BOTTOM_START_Y +
|
||||||
|
(i - ACHIEVEMENTS_TOP_SCREEN_COUNT) * ACHIEVEMENTS_LINE_HEIGHT;
|
||||||
|
const y = baseY + offset;
|
||||||
|
|
||||||
|
// needed for the sliding animation
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.fillRect(0, y, LOGICAL_WIDTH, ACHIEVEMENTS_LINE_HEIGHT);
|
||||||
|
|
||||||
|
// TODO: draw ??? for secret ones
|
||||||
|
ctx.fillStyle = isUnlocked ? "#ffffff" : "#666666";
|
||||||
|
drawCheckbox(ctx, ACHIEVEMENTS_X, y, isUnlocked);
|
||||||
|
ctx.fillText(
|
||||||
|
$t(`achievements.${achievement}`).replace("\n", " "),
|
||||||
|
ACHIEVEMENTS_X + CHECKBOX_SIZE + CHECKBOX_TEXT_GAP,
|
||||||
|
y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = store.isIntro
|
||||||
|
? store.intro.stage2Opacity
|
||||||
|
: store.isOutro
|
||||||
|
? store.outro.stage3Opacity
|
||||||
|
: 1;
|
||||||
|
assets.images.achievements.quit.draw(ctx, QUIT_X, QUIT_Y);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
render: () => null,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -12,7 +12,7 @@ const x = ref(LOGICAL_WIDTH);
|
|||||||
const isAnimating = ref(false);
|
const isAnimating = ref(false);
|
||||||
|
|
||||||
const PADDING = 4;
|
const PADDING = 4;
|
||||||
const LOGO_SIZE = assets.images.common.achievementNotificationLogo.rect.height;
|
const LOGO_SIZE = assets.images.achievements.notificationLogo.rect.height;
|
||||||
const NOTIF_WIDTH = 120;
|
const NOTIF_WIDTH = 120;
|
||||||
const NOTIF_HEIGHT = LOGO_SIZE + PADDING * 2;
|
const NOTIF_HEIGHT = LOGO_SIZE + PADDING * 2;
|
||||||
const NOTIF_Y = 3;
|
const NOTIF_Y = 3;
|
||||||
@@ -66,7 +66,7 @@ const processQueue = () => {
|
|||||||
onRender((ctx) => {
|
onRender((ctx) => {
|
||||||
if (!currentAchievement.value) return;
|
if (!currentAchievement.value) return;
|
||||||
|
|
||||||
const logo = assets.images.common.achievementNotificationLogo;
|
const logo = assets.images.achievements.notificationLogo;
|
||||||
|
|
||||||
// shadow
|
// shadow
|
||||||
ctx.fillStyle = "rgba(0, 0, 0, 0.3)";
|
ctx.fillStyle = "rgba(0, 0, 0, 0.3)";
|
||||||
123
app/components/Achievements/TopScreen.vue
Normal file
123
app/components/Achievements/TopScreen.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { onRender } = useScreen();
|
||||||
|
const store = useAchievementsScreen();
|
||||||
|
const achievementsStore = useAchievementsStore();
|
||||||
|
const { assets } = useAssets();
|
||||||
|
|
||||||
|
const PROGRESS_BAR_WIDTH = 140;
|
||||||
|
const PROGRESS_BAR_HEIGHT = 10;
|
||||||
|
const PROGRESS_BAR_X = (LOGICAL_WIDTH - PROGRESS_BAR_WIDTH) / 2;
|
||||||
|
const PROGRESS_BAR_Y = ACHIEVEMENTS_HEADER_Y + 27;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.$reset();
|
||||||
|
store.animateIntro();
|
||||||
|
});
|
||||||
|
|
||||||
|
onRender((ctx) => {
|
||||||
|
assets.images.home.topScreen.background.draw(ctx, 0, 0);
|
||||||
|
|
||||||
|
ctx.globalAlpha = store.isIntro
|
||||||
|
? store.intro.stage1Opacity
|
||||||
|
: store.isOutro
|
||||||
|
? store.outro.stage1Opacity
|
||||||
|
: 1;
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.fillRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);
|
||||||
|
|
||||||
|
// header
|
||||||
|
ctx.globalAlpha = store.isIntro
|
||||||
|
? store.intro.stage2Opacity
|
||||||
|
: store.isOutro
|
||||||
|
? store.outro.stage3Opacity
|
||||||
|
: 1;
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
|
||||||
|
// title
|
||||||
|
ctx.font = "10px NDS10";
|
||||||
|
fillTextHCentered(
|
||||||
|
ctx,
|
||||||
|
$t("achievementsScreen.title"),
|
||||||
|
0,
|
||||||
|
ACHIEVEMENTS_HEADER_Y,
|
||||||
|
LOGICAL_WIDTH,
|
||||||
|
);
|
||||||
|
|
||||||
|
// progress text
|
||||||
|
const unlockedCount = achievementsStore.achievements.length;
|
||||||
|
const totalCount = ACHIEVEMENTS.length;
|
||||||
|
|
||||||
|
ctx.font = "7px NDS7";
|
||||||
|
fillTextHCentered(
|
||||||
|
ctx,
|
||||||
|
`${unlockedCount}/${totalCount}`,
|
||||||
|
0,
|
||||||
|
ACHIEVEMENTS_HEADER_Y + 13,
|
||||||
|
LOGICAL_WIDTH,
|
||||||
|
);
|
||||||
|
|
||||||
|
// progress bar
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillRect(PROGRESS_BAR_X, PROGRESS_BAR_Y, PROGRESS_BAR_WIDTH, 1);
|
||||||
|
ctx.fillRect(
|
||||||
|
PROGRESS_BAR_X,
|
||||||
|
PROGRESS_BAR_Y + PROGRESS_BAR_HEIGHT - 1,
|
||||||
|
PROGRESS_BAR_WIDTH,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
ctx.fillRect(
|
||||||
|
PROGRESS_BAR_X + PROGRESS_BAR_WIDTH - 1,
|
||||||
|
PROGRESS_BAR_Y,
|
||||||
|
1,
|
||||||
|
PROGRESS_BAR_HEIGHT,
|
||||||
|
);
|
||||||
|
ctx.fillRect(PROGRESS_BAR_X, PROGRESS_BAR_Y, 1, PROGRESS_BAR_HEIGHT);
|
||||||
|
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
const fill =
|
||||||
|
(unlockedCount / totalCount) *
|
||||||
|
(store.isIntro ? store.intro.progressBar : 1);
|
||||||
|
ctx.fillRect(
|
||||||
|
PROGRESS_BAR_X + 2,
|
||||||
|
PROGRESS_BAR_Y + 2,
|
||||||
|
Math.ceil((PROGRESS_BAR_WIDTH - 4) * fill),
|
||||||
|
PROGRESS_BAR_HEIGHT - 4,
|
||||||
|
);
|
||||||
|
|
||||||
|
// achievement list (reversed iteration because they appear in cascade)
|
||||||
|
ctx.globalAlpha = store.isIntro
|
||||||
|
? store.intro.stage2Opacity
|
||||||
|
: store.isOutro
|
||||||
|
? store.outro.stage2Opacity
|
||||||
|
: 1;
|
||||||
|
ctx.font = "7px NDS7";
|
||||||
|
for (let i = ACHIEVEMENTS_TOP_SCREEN_COUNT - 1; i >= 0; i--) {
|
||||||
|
const achievement = ACHIEVEMENTS[i]!;
|
||||||
|
const isUnlocked = achievementsStore.isUnlocked(achievement);
|
||||||
|
|
||||||
|
const offset = store.getItemOffset(i);
|
||||||
|
if (offset === -ACHIEVEMENTS_LINE_HEIGHT) continue;
|
||||||
|
|
||||||
|
const baseY = ACHIEVEMENTS_LIST_START_Y + i * ACHIEVEMENTS_LINE_HEIGHT;
|
||||||
|
const y = baseY + offset;
|
||||||
|
|
||||||
|
// needed for the sliding animation
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.fillRect(0, y, LOGICAL_WIDTH, ACHIEVEMENTS_LINE_HEIGHT);
|
||||||
|
|
||||||
|
// TODO: draw ??? for secret ones
|
||||||
|
ctx.fillStyle = isUnlocked ? "#ffffff" : "#666666";
|
||||||
|
drawCheckbox(ctx, ACHIEVEMENTS_X, y, isUnlocked);
|
||||||
|
ctx.fillText(
|
||||||
|
$t(`achievements.${achievement}`).replace("\n", " "),
|
||||||
|
ACHIEVEMENTS_X + CHECKBOX_SIZE + CHECKBOX_TEXT_GAP,
|
||||||
|
y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
render: () => null,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -15,12 +15,16 @@ const { selected, selectorPosition } = useButtonNavigation({
|
|||||||
|
|
||||||
theme: [0, 167, 31, 26],
|
theme: [0, 167, 31, 26],
|
||||||
settings: [112, 167, 31, 26],
|
settings: [112, 167, 31, 26],
|
||||||
alarm: [225, 167, 31, 26],
|
achievements: [225, 167, 31, 26],
|
||||||
},
|
},
|
||||||
initialButton: "projects",
|
initialButton: "projects",
|
||||||
onButtonClick: (button) => {
|
onButtonClick: (button) => {
|
||||||
if (button === "theme" || button === "alarm")
|
if (button === "theme") throw new Error(`Not implemented: ${button}`);
|
||||||
throw new Error(`Not implemented: ${button}`);
|
|
||||||
|
if (button === "achievements") {
|
||||||
|
store.animateOutro("achievements");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
store.animateOutro(button);
|
store.animateOutro(button);
|
||||||
},
|
},
|
||||||
@@ -47,9 +51,9 @@ const { selected, selectorPosition } = useButtonNavigation({
|
|||||||
settings: {
|
settings: {
|
||||||
left: "theme",
|
left: "theme",
|
||||||
up: "last",
|
up: "last",
|
||||||
right: "alarm",
|
right: "achievements",
|
||||||
},
|
},
|
||||||
alarm: {
|
achievements: {
|
||||||
left: "settings",
|
left: "settings",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -168,9 +172,9 @@ onRender((ctx) => {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
:x="235"
|
:x="235"
|
||||||
:y="175 + getButtonOffset('alarm')"
|
:y="175 + getButtonOffset('achievements')"
|
||||||
:opacity="getOpacity('alarm')"
|
:opacity="getOpacity('achievements')"
|
||||||
:image="assets.images.home.bottomScreen.buttons.alarm"
|
:image="assets.images.home.bottomScreen.buttons.achievements"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Selector :rect="selectorPosition" :opacity="getOpacity()" />
|
<Selector :rect="selectorPosition" :opacity="getOpacity()" />
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ useKeyUp((key) => {
|
|||||||
<ProjectsTopScreen v-else-if="app.screen === 'projects'" />
|
<ProjectsTopScreen v-else-if="app.screen === 'projects'" />
|
||||||
<SettingsTopScreen v-else-if="app.screen === 'settings'" />
|
<SettingsTopScreen v-else-if="app.screen === 'settings'" />
|
||||||
<GalleryTopScreen v-else-if="app.screen === 'gallery'" />
|
<GalleryTopScreen v-else-if="app.screen === 'gallery'" />
|
||||||
|
<AchievementsTopScreen v-else-if="app.screen === 'achievements'" />
|
||||||
|
|
||||||
<CommonAchievementNotification />
|
<AchievementsNotification />
|
||||||
</Screen>
|
</Screen>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
124
app/stores/achievementsScreen.ts
Normal file
124
app/stores/achievementsScreen.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import gsap from "gsap";
|
||||||
|
|
||||||
|
export const useAchievementsScreen = defineStore("achievementsScreen", {
|
||||||
|
state: () => ({
|
||||||
|
intro: {
|
||||||
|
stage1Opacity: 0,
|
||||||
|
stage2Opacity: 0,
|
||||||
|
itemOffsets: {} as Record<number, number>,
|
||||||
|
progressBar: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
outro: {
|
||||||
|
stage1Opacity: 1,
|
||||||
|
stage2Opacity: 1,
|
||||||
|
stage3Opacity: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
isIntro: true,
|
||||||
|
isOutro: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
animateIntro() {
|
||||||
|
this.isIntro = true;
|
||||||
|
this.isOutro = false;
|
||||||
|
|
||||||
|
const itemCount = ACHIEVEMENTS.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < itemCount; i++) {
|
||||||
|
this.intro.itemOffsets[i] = -ACHIEVEMENTS_LINE_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
this.isIntro = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tl.fromTo(
|
||||||
|
this.intro,
|
||||||
|
{ stage1Opacity: 0 },
|
||||||
|
{
|
||||||
|
stage1Opacity: 1,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "none",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.fromTo(
|
||||||
|
this.intro,
|
||||||
|
{ stage2Opacity: 0 },
|
||||||
|
{
|
||||||
|
stage2Opacity: 1,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: "none",
|
||||||
|
},
|
||||||
|
0.5,
|
||||||
|
)
|
||||||
|
.fromTo(
|
||||||
|
this.intro,
|
||||||
|
{ progressBar: 0 },
|
||||||
|
{
|
||||||
|
progressBar: 1,
|
||||||
|
duration: 1.5,
|
||||||
|
ease: "power2.out",
|
||||||
|
},
|
||||||
|
0.25,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < itemCount; i++) {
|
||||||
|
tl.to(
|
||||||
|
this.intro.itemOffsets,
|
||||||
|
{
|
||||||
|
[i]: 0,
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "power2.out",
|
||||||
|
},
|
||||||
|
0.75 + i * 0.05,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
animateOutro() {
|
||||||
|
this.isIntro = false;
|
||||||
|
this.isOutro = true;
|
||||||
|
|
||||||
|
gsap
|
||||||
|
.timeline()
|
||||||
|
.fromTo(
|
||||||
|
this.outro,
|
||||||
|
{ stage2Opacity: 1 },
|
||||||
|
{
|
||||||
|
stage2Opacity: 0,
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.fromTo(
|
||||||
|
this.outro,
|
||||||
|
{ stage3Opacity: 1 },
|
||||||
|
{
|
||||||
|
stage3Opacity: 0,
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
"-=0.15",
|
||||||
|
)
|
||||||
|
.fromTo(
|
||||||
|
this.outro,
|
||||||
|
{ stage1Opacity: 1 },
|
||||||
|
{
|
||||||
|
stage1Opacity: 0,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "none",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.call(() => {
|
||||||
|
const app = useAppStore();
|
||||||
|
app.navigateTo("home");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getItemOffset(index: number): number {
|
||||||
|
return this.intro.itemOffsets[index] ?? 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
8
app/types/app.d.ts
vendored
8
app/types/app.d.ts
vendored
@@ -1 +1,7 @@
|
|||||||
type AppScreen = "home" | "contact" | "projects" | "settings" | "gallery";
|
type AppScreen =
|
||||||
|
| "home"
|
||||||
|
| "contact"
|
||||||
|
| "projects"
|
||||||
|
| "settings"
|
||||||
|
| "gallery"
|
||||||
|
| "achievements";
|
||||||
|
|||||||
30
app/utils/achievements.ts
Normal file
30
app/utils/achievements.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export const ACHIEVEMENTS_LINE_HEIGHT = 14;
|
||||||
|
export const ACHIEVEMENTS_HEADER_Y = 20;
|
||||||
|
export const ACHIEVEMENTS_LIST_START_Y = ACHIEVEMENTS_HEADER_Y + 55;
|
||||||
|
export const ACHIEVEMENTS_BOTTOM_START_Y = 10;
|
||||||
|
export const ACHIEVEMENTS_TOP_SCREEN_COUNT = Math.floor(
|
||||||
|
(LOGICAL_HEIGHT - ACHIEVEMENTS_LIST_START_Y) / ACHIEVEMENTS_LINE_HEIGHT,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CHECKBOX_SIZE = 7;
|
||||||
|
export const CHECKBOX_TEXT_GAP = 5;
|
||||||
|
export const ACHIEVEMENTS_X = 55;
|
||||||
|
|
||||||
|
export const drawCheckbox = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
checked: boolean,
|
||||||
|
) => {
|
||||||
|
ctx.fillRect(x, y, CHECKBOX_SIZE, 1);
|
||||||
|
ctx.fillRect(x, y + CHECKBOX_SIZE - 1, CHECKBOX_SIZE, 1);
|
||||||
|
ctx.fillRect(x, y + 1, 1, CHECKBOX_SIZE - 2);
|
||||||
|
ctx.fillRect(x + CHECKBOX_SIZE - 1, y + 1, 1, CHECKBOX_SIZE - 2);
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
for (let i = 2; i < CHECKBOX_SIZE - 2; i++) {
|
||||||
|
ctx.fillRect(x + i, y + i, 1, 1);
|
||||||
|
ctx.fillRect(x + CHECKBOX_SIZE - 1 - i, y + i, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"achievementsScreen": {
|
||||||
|
"title": "Achievements"
|
||||||
|
},
|
||||||
"achievements": {
|
"achievements": {
|
||||||
"boot": "Boot up the system",
|
"boot": "Boot up the system",
|
||||||
"projects_visit": "Visit the projects\nsection",
|
"projects_visit": "Visit the projects\nsection",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 78 B After Width: | Height: | Size: 78 B |
BIN
public/nds/images/achievements/quit.webp
Normal file
BIN
public/nds/images/achievements/quit.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 B |
BIN
public/nds/images/home/bottom-screen/buttons/achievements.webp
Normal file
BIN
public/nds/images/home/bottom-screen/buttons/achievements.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 B |
Binary file not shown.
|
Before Width: | Height: | Size: 66 B |
Reference in New Issue
Block a user