feat(achievements): implement unlocking, saving and notification

This commit is contained in:
2026-01-18 22:50:35 +01:00
parent d61a60132a
commit d7e626397a
13 changed files with 269 additions and 2 deletions

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import gsap from "gsap";
import type { Achievement } from "~/stores/achievements";
const { onRender } = useScreen();
const achievements = useAchievementsStore();
const { assets } = useAssets();
const queue = ref<Achievement[]>([]);
const currentAchievement = ref<Achievement | null>(null);
const x = ref(LOGICAL_WIDTH);
const isAnimating = ref(false);
const PADDING = 4;
const LOGO_SIZE = assets.images.common.achievementNotificationLogo.rect.height;
const NOTIF_HEIGHT = LOGO_SIZE + PADDING * 2;
const NOTIF_WIDTH = 120;
const NOTIF_Y = 3;
const NOTIF_X_VISIBLE = LOGICAL_WIDTH - NOTIF_WIDTH;
achievements.$onAction(({ name, args, after }) => {
if (name === "unlock") {
after((wasUnlocked) => {
if (wasUnlocked) {
queue.value.push(args[0]);
processQueue();
}
});
}
});
const processQueue = () => {
if (isAnimating.value || queue.value.length === 0) return;
const next = queue.value.shift();
if (!next) return;
currentAchievement.value = next;
isAnimating.value = true;
gsap
.timeline()
.to(x, {
value: NOTIF_X_VISIBLE,
duration: 0.3,
ease: "power2.out",
})
.to(
x,
{
value: LOGICAL_WIDTH,
duration: 0.3,
ease: "power2.in",
},
"+=2.5",
)
.call(() => {
currentAchievement.value = null;
isAnimating.value = false;
processQueue();
});
};
onRender((ctx) => {
if (!currentAchievement.value) return;
const logo = assets.images.common.achievementNotificationLogo;
const textOffset = LOGO_SIZE + PADDING * 2;
// Shadow
ctx.fillStyle = "rgba(0, 0, 0, 0.3)";
ctx.fillRect(x.value + 1, NOTIF_Y + 1, NOTIF_WIDTH, NOTIF_HEIGHT);
// Outer border (dark) - no right border
ctx.fillStyle = "#282828";
ctx.fillRect(x.value, NOTIF_Y, NOTIF_WIDTH, 1); // Top
ctx.fillRect(x.value, NOTIF_Y, 1, NOTIF_HEIGHT); // Left
ctx.fillRect(x.value, NOTIF_Y + NOTIF_HEIGHT - 1, NOTIF_WIDTH, 1); // Bottom
// Inner background (light)
ctx.fillStyle = "#fafafa";
ctx.fillRect(x.value + 1, NOTIF_Y + 1, NOTIF_WIDTH - 1, NOTIF_HEIGHT - 2);
// Logo
logo.draw(ctx, x.value + PADDING, NOTIF_Y + PADDING);
// Text
ctx.font = "8px NDS10";
ctx.textBaseline = "top";
ctx.fillStyle = "#282828";
ctx.fillText(
$t(`achievements.${currentAchievement.value}`),
x.value + textOffset,
NOTIF_Y + PADDING + (LOGO_SIZE - 8) / 2,
);
}, 200);
defineOptions({ render: () => null });
</script>

View File

@@ -6,6 +6,7 @@ import Buttons from "./Buttons.vue";
import ButtonSelector from "~/components/Common/ButtonSelector.vue"; import ButtonSelector from "~/components/Common/ButtonSelector.vue";
const store = useContactStore(); const store = useContactStore();
const achievements = useAchievementsStore();
const confirmationModal = useConfirmationModal(); const confirmationModal = useConfirmationModal();
const ACTIONS = { const ACTIONS = {
@@ -65,6 +66,12 @@ const actionateButton = async (button: (typeof selected)["value"]) => {
store.pushNotification(`${verb} opened`); store.pushNotification(`${verb} opened`);
await sleep(100); await sleep(100);
await navigateTo(content, { open: { target: "_blank " } }); await navigateTo(content, { open: { target: "_blank " } });
await sleep(500);
if (button === "github") {
achievements.unlock("contact_git_visit");
}
}, },
}); });
} }

View File

@@ -2,6 +2,7 @@
const { locales, locale, setLocale } = useI18n(); const { locales, locale, setLocale } = useI18n();
const store = useSettingsStore(); const store = useSettingsStore();
const confirmationModal = useConfirmationModal(); const confirmationModal = useConfirmationModal();
const achievements = useAchievementsStore();
const { assets } = useAssets(); const { assets } = useAssets();
const { onRender } = useScreen(); const { onRender } = useScreen();
@@ -74,6 +75,13 @@ const handleConfirm = () => {
setLocale(selectedLocale.code); 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({ confirmationModal.open({
text: $t( text: $t(
"settings.options.language.confirmation", "settings.options.language.confirmation",

View File

@@ -5,6 +5,7 @@ const app = useAppStore();
const store = useSettingsStore(); const store = useSettingsStore();
const { assets } = useAssets(); const { assets } = useAssets();
const confirmationModal = useConfirmationModal(); const confirmationModal = useConfirmationModal();
const achievements = useAchievementsStore();
const GRID_SIZE = 4; const GRID_SIZE = 4;
const GRID_START_X = 32; const GRID_START_X = 32;
@@ -15,6 +16,7 @@ const ANIMATION_SPEED = 475;
const originalSelectedCol = app.color.col; const originalSelectedCol = app.color.col;
const originalSelectedRow = app.color.row; const originalSelectedRow = app.color.row;
const originalColor = app.color.hex;
let selectedCol = app.color.col; let selectedCol = app.color.col;
let selectedRow = app.color.row; let selectedRow = app.color.row;
@@ -108,6 +110,18 @@ const handleCancel = () => {
const handleConfirm = () => { const handleConfirm = () => {
app.save(); 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({ confirmationModal.open({
text: $t("settings.user.color.confirmation"), text: $t("settings.user.color.confirmation"),
onClosed: () => store.closeSubMenu(), onClosed: () => store.closeSubMenu(),

View File

@@ -3,6 +3,7 @@ import * as THREE from "three";
import { useIntervalFn, useLocalStorage } from "@vueuse/core"; import { useIntervalFn, useLocalStorage } from "@vueuse/core";
const store = useSettingsStore(); const store = useSettingsStore();
const achievements = useAchievementsStore();
const confirmationModal = useConfirmationModal(); const confirmationModal = useConfirmationModal();
const { onRender } = useScreen(); const { onRender } = useScreen();
@@ -56,7 +57,11 @@ const handleConfirm = () => {
break; break;
} }
case "waiting": case "waiting": {
achievements.unlock("snake_play");
spawn();
break;
}
case "dead": { case "dead": {
spawn(); spawn();
break; break;
@@ -101,6 +106,10 @@ const eat = () => {
highScore.value = Math.max(highScore.value, score); highScore.value = Math.max(highScore.value, score);
food.copy(randomFoodPos()); food.copy(randomFoodPos());
score += 1; score += 1;
if (score === 40) {
achievements.unlock("snake_score_40");
}
}; };
const die = () => { const die = () => {

View File

@@ -76,6 +76,8 @@ 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'" />
<CommonAchievementNotification />
</Screen> </Screen>
</div> </div>
<div> <div>

View File

@@ -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),
),
};
});

View File

@@ -41,6 +41,22 @@ export const useAppStore = defineStore("app", {
navigateTo(screen: AppScreen) { navigateTo(screen: AppScreen) {
this.screen = screen; 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) { setCamera(camera: THREE.Camera) {

View File

@@ -82,6 +82,11 @@ export const useContactStore = defineStore("contact", {
{ notificationsYOffset: 20 }, { notificationsYOffset: 20 },
{ notificationsYOffset: 0, duration: 0.075 }, { notificationsYOffset: 0, duration: 0.075 },
); );
if (this.notifications.length === 36) {
const achievements = useAchievementsStore();
achievements.unlock("contact_36_notifications");
}
}, },
animateOutro() { animateOutro() {

View File

@@ -68,7 +68,9 @@ export const useIntroStore = defineStore("intro", {
}) })
.call(() => { .call(() => {
const app = useAppStore(); const app = useAppStore();
const achievements = useAchievementsStore();
app.booted = true; app.booted = true;
achievements.unlock("boot");
}); });
}, },
}, },

View File

@@ -61,7 +61,11 @@ export const useProjectsStore = defineStore("projects", {
visitProject() { visitProject() {
const link = this.projects[this.currentProject]?.link; 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") { 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 // TODO: not used anymore

View File

@@ -14,6 +14,17 @@ export const useSettingsStore = defineStore("settings", {
openSubMenu(submenu: SettingsSubMenu) { openSubMenu(submenu: SettingsSubMenu) {
this.currentSubMenu = submenu; 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() { closeSubMenu() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 B