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>
|
||||
103
app/components/Achievements/Notification.vue
Normal file
103
app/components/Achievements/Notification.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<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.achievements.notificationLogo.rect.height;
|
||||
const NOTIF_WIDTH = 120;
|
||||
const NOTIF_HEIGHT = LOGO_SIZE + PADDING * 2;
|
||||
const NOTIF_Y = 3;
|
||||
const NOTIF_X_VISIBLE = LOGICAL_WIDTH - NOTIF_WIDTH;
|
||||
const TEXT_X_OFFSET = LOGO_SIZE + PADDING * 2;
|
||||
const LINE_HEIGHT = 8;
|
||||
|
||||
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.achievements.notificationLogo;
|
||||
|
||||
// shadow
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.3)";
|
||||
ctx.fillRect(x.value + 1, NOTIF_Y + 1, NOTIF_WIDTH, NOTIF_HEIGHT);
|
||||
|
||||
// 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
|
||||
|
||||
// background
|
||||
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 (1 or 2 lines)
|
||||
ctx.font = "7px NDS7";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = "#282828";
|
||||
|
||||
const lines = $t(`achievements.${currentAchievement.value}`).split("\n");
|
||||
const textY =
|
||||
lines.length === 1 ? NOTIF_Y + PADDING + 4 : NOTIF_Y + PADDING + 1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
ctx.fillText(lines[i]!, x.value + TEXT_X_OFFSET, textY + i * LINE_HEIGHT);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
defineOptions({ render: () => null });
|
||||
</script>
|
||||
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>
|
||||
Reference in New Issue
Block a user