feat(achievements): implement achievements screen

This commit is contained in:
2026-01-20 19:18:12 +01:00
parent 425195cf5f
commit fffed80faa
13 changed files with 394 additions and 12 deletions

View 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>

View File

@@ -12,7 +12,7 @@ const x = ref(LOGICAL_WIDTH);
const isAnimating = ref(false);
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_HEIGHT = LOGO_SIZE + PADDING * 2;
const NOTIF_Y = 3;
@@ -66,7 +66,7 @@ const processQueue = () => {
onRender((ctx) => {
if (!currentAchievement.value) return;
const logo = assets.images.common.achievementNotificationLogo;
const logo = assets.images.achievements.notificationLogo;
// shadow
ctx.fillStyle = "rgba(0, 0, 0, 0.3)";

View 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>

View File

@@ -15,12 +15,16 @@ const { selected, selectorPosition } = useButtonNavigation({
theme: [0, 167, 31, 26],
settings: [112, 167, 31, 26],
alarm: [225, 167, 31, 26],
achievements: [225, 167, 31, 26],
},
initialButton: "projects",
onButtonClick: (button) => {
if (button === "theme" || button === "alarm")
throw new Error(`Not implemented: ${button}`);
if (button === "theme") throw new Error(`Not implemented: ${button}`);
if (button === "achievements") {
store.animateOutro("achievements");
return;
}
store.animateOutro(button);
},
@@ -47,9 +51,9 @@ const { selected, selectorPosition } = useButtonNavigation({
settings: {
left: "theme",
up: "last",
right: "alarm",
right: "achievements",
},
alarm: {
achievements: {
left: "settings",
},
},
@@ -168,9 +172,9 @@ onRender((ctx) => {
/>
<Button
:x="235"
:y="175 + getButtonOffset('alarm')"
:opacity="getOpacity('alarm')"
:image="assets.images.home.bottomScreen.buttons.alarm"
:y="175 + getButtonOffset('achievements')"
:opacity="getOpacity('achievements')"
:image="assets.images.home.bottomScreen.buttons.achievements"
/>
<Selector :rect="selectorPosition" :opacity="getOpacity()" />

View File

@@ -76,8 +76,9 @@ useKeyUp((key) => {
<ProjectsTopScreen v-else-if="app.screen === 'projects'" />
<SettingsTopScreen v-else-if="app.screen === 'settings'" />
<GalleryTopScreen v-else-if="app.screen === 'gallery'" />
<AchievementsTopScreen v-else-if="app.screen === 'achievements'" />
<CommonAchievementNotification />
<AchievementsNotification />
</Screen>
</div>
<div>

View 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
View File

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