diff --git a/app/components/Common/AchievementNotification.vue b/app/components/Common/AchievementNotification.vue new file mode 100644 index 0000000..791ebed --- /dev/null +++ b/app/components/Common/AchievementNotification.vue @@ -0,0 +1,99 @@ + diff --git a/app/components/Contact/BottomScreen/BottomScreen.vue b/app/components/Contact/BottomScreen/BottomScreen.vue index 9d3fff7..4c398fb 100644 --- a/app/components/Contact/BottomScreen/BottomScreen.vue +++ b/app/components/Contact/BottomScreen/BottomScreen.vue @@ -6,6 +6,7 @@ import Buttons from "./Buttons.vue"; import ButtonSelector from "~/components/Common/ButtonSelector.vue"; const store = useContactStore(); +const achievements = useAchievementsStore(); const confirmationModal = useConfirmationModal(); const ACTIONS = { @@ -65,6 +66,12 @@ const actionateButton = async (button: (typeof selected)["value"]) => { store.pushNotification(`${verb} opened`); await sleep(100); await navigateTo(content, { open: { target: "_blank " } }); + + await sleep(500); + + if (button === "github") { + achievements.unlock("contact_git_visit"); + } }, }); } diff --git a/app/components/Settings/BottomScreen/Menus/Options/Language.vue b/app/components/Settings/BottomScreen/Menus/Options/Language.vue index 4d2f897..79f7a5f 100644 --- a/app/components/Settings/BottomScreen/Menus/Options/Language.vue +++ b/app/components/Settings/BottomScreen/Menus/Options/Language.vue @@ -2,6 +2,7 @@ const { locales, locale, setLocale } = useI18n(); const store = useSettingsStore(); const confirmationModal = useConfirmationModal(); +const achievements = useAchievementsStore(); const { assets } = useAssets(); const { onRender } = useScreen(); @@ -74,6 +75,13 @@ const handleConfirm = () => { setLocale(selectedLocale.code); + if (!achievements.advancement.languages.includes(selectedLocale.code)) { + achievements.advancement.languages.push(selectedLocale.code); + if (achievements.advancement.languages.length === locales.value.length) { + achievements.unlock("settings_language_try_all"); + } + } + confirmationModal.open({ text: $t( "settings.options.language.confirmation", diff --git a/app/components/Settings/BottomScreen/Menus/User/Color.vue b/app/components/Settings/BottomScreen/Menus/User/Color.vue index f12f6b6..5a16cd5 100644 --- a/app/components/Settings/BottomScreen/Menus/User/Color.vue +++ b/app/components/Settings/BottomScreen/Menus/User/Color.vue @@ -5,6 +5,7 @@ const app = useAppStore(); const store = useSettingsStore(); const { assets } = useAssets(); const confirmationModal = useConfirmationModal(); +const achievements = useAchievementsStore(); const GRID_SIZE = 4; const GRID_START_X = 32; @@ -15,6 +16,7 @@ const ANIMATION_SPEED = 475; const originalSelectedCol = app.color.col; const originalSelectedRow = app.color.row; +const originalColor = app.color.hex; let selectedCol = app.color.col; let selectedRow = app.color.row; @@ -108,6 +110,18 @@ const handleCancel = () => { const handleConfirm = () => { app.save(); + + if (app.color.hex !== originalColor) { + achievements.unlock("settings_color_change"); + } + + if (!achievements.advancement.colors.includes(app.color.hex)) { + achievements.advancement.colors.push(app.color.hex); + if (achievements.advancement.colors.length === APP_COLORS.flat().length) { + achievements.unlock("settings_color_try_all"); + } + } + confirmationModal.open({ text: $t("settings.user.color.confirmation"), onClosed: () => store.closeSubMenu(), diff --git a/app/components/Settings/BottomScreen/Menus/User/PersonalMessage.vue b/app/components/Settings/BottomScreen/Menus/User/PersonalMessage.vue index 42d9f5e..bb2d6fd 100644 --- a/app/components/Settings/BottomScreen/Menus/User/PersonalMessage.vue +++ b/app/components/Settings/BottomScreen/Menus/User/PersonalMessage.vue @@ -3,6 +3,7 @@ import * as THREE from "three"; import { useIntervalFn, useLocalStorage } from "@vueuse/core"; const store = useSettingsStore(); +const achievements = useAchievementsStore(); const confirmationModal = useConfirmationModal(); const { onRender } = useScreen(); @@ -56,7 +57,11 @@ const handleConfirm = () => { break; } - case "waiting": + case "waiting": { + achievements.unlock("snake_play"); + spawn(); + break; + } case "dead": { spawn(); break; @@ -101,6 +106,10 @@ const eat = () => { highScore.value = Math.max(highScore.value, score); food.copy(randomFoodPos()); score += 1; + + if (score === 40) { + achievements.unlock("snake_score_40"); + } }; const die = () => { diff --git a/app/pages/index.vue b/app/pages/index.vue index c69bd4a..59d8fc5 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -76,6 +76,8 @@ useKeyUp((key) => { + +
diff --git a/app/stores/achievements.ts b/app/stores/achievements.ts new file mode 100644 index 0000000..dbe8fdf --- /dev/null +++ b/app/stores/achievements.ts @@ -0,0 +1,83 @@ +import { useLocalStorage } from "@vueuse/core"; + +const STORAGE_ID = "achievements"; + +export const ACHIEVEMENTS = [ + "boot", + // projects + "projects_visit", + "projects_view_5", + "projects_open_link", + // gallery + "gallery_visit", + // contact + "contact_visit", + "contact_git_visit", + // settings + "settings_color_change", + // snake + "snake_play", + "snake_score_40", + // secrets + "settings_color_try_all", + "settings_language_try_all", + "settings_visit_all", + "contact_36_notifications", +] as const; + +export type Achievement = (typeof ACHIEVEMENTS)[number]; + +export const useAchievementsStore = defineStore("achievements", () => { + const app = useAppStore(); + const { locale } = useI18n(); + + const storage = useLocalStorage( + STORAGE_ID, + { + unlocked: [] as Achievement[], + advancement: { + colors: [app.color.hex], + languages: [locale.value], + visitedSettings: [] as string[], + }, + }, + { mergeDefaults: true }, + ); + + if (!storage.value.advancement.colors.includes(app.color.hex)) { + storage.value.advancement.colors.push(app.color.hex); + } + if (!storage.value.advancement.languages.includes(locale.value)) { + storage.value.advancement.languages.push(locale.value); + } + + const unlock = (name: Achievement) => { + if (storage.value.unlocked.includes(name)) { + return false; + } + + storage.value.unlocked.push(name); + return true; + }; + + const reset = () => { + storage.value = { + unlocked: [], + advancement: { + colors: [app.color.hex], + languages: [locale.value], + visitedSettings: [], + }, + }; + }; + + return { + achievements: computed(() => storage.value.unlocked), + advancement: computed(() => storage.value.advancement), + unlock, + reset, + isUnlocked: computed( + () => (name: Achievement) => storage.value.unlocked.includes(name), + ), + }; +}); diff --git a/app/stores/app.ts b/app/stores/app.ts index f1397ab..3521033 100644 --- a/app/stores/app.ts +++ b/app/stores/app.ts @@ -41,6 +41,22 @@ export const useAppStore = defineStore("app", { navigateTo(screen: AppScreen) { this.screen = screen; + + const achievements = useAchievementsStore(); + + switch (screen) { + case "projects": + achievements.unlock("projects_visit"); + break; + + case "gallery": + achievements.unlock("gallery_visit"); + break; + + case "contact": + achievements.unlock("contact_visit"); + break; + } }, setCamera(camera: THREE.Camera) { diff --git a/app/stores/contact.ts b/app/stores/contact.ts index 290aa22..b65a817 100644 --- a/app/stores/contact.ts +++ b/app/stores/contact.ts @@ -82,6 +82,11 @@ export const useContactStore = defineStore("contact", { { notificationsYOffset: 20 }, { notificationsYOffset: 0, duration: 0.075 }, ); + + if (this.notifications.length === 36) { + const achievements = useAchievementsStore(); + achievements.unlock("contact_36_notifications"); + } }, animateOutro() { diff --git a/app/stores/intro.ts b/app/stores/intro.ts index 70875ad..9a1b1c9 100644 --- a/app/stores/intro.ts +++ b/app/stores/intro.ts @@ -68,7 +68,9 @@ export const useIntroStore = defineStore("intro", { }) .call(() => { const app = useAppStore(); + const achievements = useAchievementsStore(); app.booted = true; + achievements.unlock("boot"); }); }, }, diff --git a/app/stores/projects.ts b/app/stores/projects.ts index 2b5c216..ebbcc95 100644 --- a/app/stores/projects.ts +++ b/app/stores/projects.ts @@ -61,7 +61,11 @@ export const useProjectsStore = defineStore("projects", { visitProject() { const link = this.projects[this.currentProject]?.link; - if (link) navigateTo(link, { open: { target: "_blank" } }); + if (link) { + navigateTo(link, { open: { target: "_blank" } }); + const achievements = useAchievementsStore(); + achievements.unlock("projects_open_link"); + } }, scrollProjects(direction: "left" | "right") { @@ -89,6 +93,13 @@ export const useProjectsStore = defineStore("projects", { }, ); } + + if (this.currentProject >= 4) { + setTimeout(() => { + const achievements = useAchievementsStore(); + achievements.unlock("projects_view_5"); + }, 500); + } }, // TODO: not used anymore diff --git a/app/stores/settings.ts b/app/stores/settings.ts index 018afe3..d543075 100644 --- a/app/stores/settings.ts +++ b/app/stores/settings.ts @@ -14,6 +14,17 @@ export const useSettingsStore = defineStore("settings", { openSubMenu(submenu: SettingsSubMenu) { this.currentSubMenu = submenu; + + const achievements = useAchievementsStore(); + if (!achievements.advancement.visitedSettings.includes(submenu)) { + achievements.advancement.visitedSettings.push(submenu); + } + if ( + achievements.advancement.visitedSettings.length === + SETTINGS_SUB_MENUS.length + ) { + achievements.unlock("settings_visit_all"); + } }, closeSubMenu() { diff --git a/public/nds/images/common/achievement-notification-logo.webp b/public/nds/images/common/achievement-notification-logo.webp new file mode 100644 index 0000000..aab8d5f Binary files /dev/null and b/public/nds/images/common/achievement-notification-logo.webp differ