feat(achievements): implement unlocking, saving and notification
This commit is contained in:
99
app/components/Common/AchievementNotification.vue
Normal file
99
app/components/Common/AchievementNotification.vue
Normal 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>
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
83
app/stores/achievements.ts
Normal file
83
app/stores/achievements.ts
Normal 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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
BIN
public/nds/images/common/achievement-notification-logo.webp
Normal file
BIN
public/nds/images/common/achievement-notification-logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 B |
Reference in New Issue
Block a user