feat(settings): more specific notification handling, and put menu in query to allow user to go back in history to go back in menus

This commit is contained in:
2025-11-26 22:58:03 +01:00
parent 4d9371f1b0
commit a5c1c93260
9 changed files with 181 additions and 128 deletions

View File

@@ -1,44 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import Background from "./Background.vue"; import Background from "./Background.vue";
import Menus from "./Menus/Menus.vue"; import Menus from "./Menus/Menus.vue";
import OptionsStartUp from "./Menus/Options/StartUp.vue";
import OptionsLanguage from "./Menus/Options/Language.vue";
import OptionsGbaMode from "./Menus/Options/GbaMode.vue";
const store = useSettingsStore();
const viewComponents: Record<string, Component> = {
optionsStartUp: OptionsStartUp,
optionsLanguage: OptionsLanguage,
optionsGbaMode: OptionsGbaMode,
};
const currentViewComponent = computed(() => {
if (store.currentView === "menu" || !store.currentView) return null;
return viewComponents[store.currentView] || null;
});
const handleBackNavigation = (event: KeyboardEvent) => {
if (event.key === "Escape" || event.key === "Backspace") {
if (store.navigationStack.length > 0) {
store.popNavigation();
event.preventDefault();
}
}
};
onMounted(() => {
store.$reset();
window.addEventListener("keydown", handleBackNavigation);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleBackNavigation);
});
</script> </script>
<template> <template>
<Background /> <Background />
<Menus v-if="store.currentView === 'menu'" /> <Menus />
<component :is="currentViewComponent" v-else-if="currentViewComponent" />
</template> </template>

View File

@@ -34,7 +34,7 @@ const isAnyOtherMenuOpen = computed(() =>
settingsStore.isAnyOtherMenuOpen("clock"), settingsStore.isAnyOtherMenuOpen("clock"),
); );
const animation = useMenuAnimation(isOpen); const animation = useMenuAnimation("clock", isOpen);
useRender((ctx) => { useRender((ctx) => {
ctx.translate(props.x, props.y); ctx.translate(props.x, props.y);

View File

@@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import OptionsMenu from "./Options/Menu.vue"; import OptionsMenu from "./Options/Menu.vue";
import OptionsStartUp from "./Options/StartUp.vue";
import OptionsLanguage from "./Options/Language.vue";
import OptionsGbaMode from "./Options/GbaMode.vue";
import ClockMenu from "./Clock/Menu.vue"; import ClockMenu from "./Clock/Menu.vue";
import UserMenu from "./User/Menu.vue"; import UserMenu from "./User/Menu.vue";
import TouchScreenMenu from "./TouchScreen/Menu.vue"; import TouchScreenMenu from "./TouchScreen/Menu.vue";
import Selector from "~/components/Common/ButtonSelector.vue"; import Selector from "~/components/Common/ButtonSelector.vue";
const settingsStore = useSettingsStore();
const { selectedButton: selected, selectorPosition } = useButtonNavigation({ const { selectedButton: selected, selectorPosition } = useButtonNavigation({
buttons: { buttons: {
options: [31, 119, 49, 49], options: [31, 119, 49, 49],
@@ -28,7 +34,11 @@ const { selectedButton: selected, selectorPosition } = useButtonNavigation({
initialButton: "options", initialButton: "options",
onButtonClick: (buttonName: string) => { onButtonClick: (buttonName: string) => {
if (isSubmenu(buttonName)) { if (isSubmenu(buttonName)) {
settingsStore.pushNavigation(buttonName); router.push({
query: {
menu: buttonName,
},
});
} }
}, },
navigation: { navigation: {
@@ -104,8 +114,6 @@ const { selectedButton: selected, selectorPosition } = useButtonNavigation({
}, },
}); });
const settingsStore = useSettingsStore();
const isSubmenu = (buttonName: string) => { const isSubmenu = (buttonName: string) => {
return ( return (
/^(options|clock|user|touchScreen)[A-Z]/.test(buttonName) && /^(options|clock|user|touchScreen)[A-Z]/.test(buttonName) &&
@@ -113,20 +121,64 @@ const isSubmenu = (buttonName: string) => {
); );
}; };
const router = useRouter();
onBeforeRouteUpdate((to, from) => {
const fromMenu = from.query.menu?.toString();
const toMenu = to.query.menu?.toString();
if (!fromMenu && toMenu) {
settingsStore.setActiveMenu(selected.value);
} else if (fromMenu && !toMenu) {
if (fromMenu === "options" || fromMenu === "clock" || fromMenu === "user") {
selected.value = fromMenu;
settingsStore.setActiveMenu(null);
} else {
throw new Error("Unreachable");
}
} else if (fromMenu && toMenu) {
if (toMenu === "options" || toMenu === "clock" || toMenu === "user") {
settingsStore.setCurrentSubMenu(null);
settingsStore.setActiveMenu(selected.value);
} else {
settingsStore.setCurrentSubMenu(toMenu);
}
}
});
watch( watch(
selected, selected,
(newSelected) => { (newSelected) => {
settingsStore.setActiveMenu(newSelected); if (settingsStore.currentSubMenu === null) {
if (isSubmenu(newSelected)) {
router.push({
query: {
menu: newSelected.split(/[A-Z]/)[0],
},
});
} else {
router.push({ query: { menu: undefined } });
}
}
}, },
{ immediate: true }, { immediate: true },
); );
const viewComponents: Record<string, Component> = {
optionsStartUp: OptionsStartUp,
optionsLanguage: OptionsLanguage,
optionsGbaMode: OptionsGbaMode,
};
</script> </script>
<template> <template>
<OptionsMenu :x="33" :y="121" /> <template v-if="!settingsStore.currentSubMenu">
<ClockMenu :x="81" :y="121" /> <OptionsMenu :x="33" :y="121" />
<UserMenu :x="129" :y="121" /> <ClockMenu :x="81" :y="121" />
<TouchScreenMenu :x="177" :y="121" :opacity="1" /> <UserMenu :x="129" :y="121" />
<TouchScreenMenu :x="177" :y="121" :opacity="1" />
<Selector :rect="selectorPosition" :opacity="1" /> <Selector :rect="selectorPosition" :opacity="1" />
</template>
<component :is="viewComponents[settingsStore.currentSubMenu]" v-else />
</template> </template>

View File

@@ -34,7 +34,7 @@ const isAnyOtherMenuOpen = computed(() =>
settingsStore.isAnyOtherMenuOpen("options"), settingsStore.isAnyOtherMenuOpen("options"),
); );
const animation = useMenuAnimation(isOpen); const animation = useMenuAnimation("options", isOpen);
useRender((ctx) => { useRender((ctx) => {
ctx.translate(props.x, props.y); ctx.translate(props.x, props.y);

View File

@@ -37,7 +37,7 @@ const isAnyOtherMenuOpen = computed(() =>
settingsStore.isAnyOtherMenuOpen("user"), settingsStore.isAnyOtherMenuOpen("user"),
); );
const animation = useMenuAnimation(isOpen); const animation = useMenuAnimation("user", isOpen);
useRender((ctx) => { useRender((ctx) => {
ctx.translate(props.x, props.y); ctx.translate(props.x, props.y);

View File

@@ -33,25 +33,6 @@ const [
GBA_MODE_IMAGE, GBA_MODE_IMAGE,
); );
const activeMenu = computed(() => {
if (!store.activeMenu) return null;
if (/^options[A-Z]/.test(store.activeMenu)) {
return { id: "options", image: optionsImage };
}
if (/^clock[A-Z]/.test(store.activeMenu)) {
return { id: "clock", image: clockImage };
}
if (/^user[A-Z]/.test(store.activeMenu)) {
return { id: "user", image: userImage };
}
if (/^touchScreen[A-Z]/.test(store.activeMenu)) {
return { id: "touchScreen", image: touchScreenImage };
}
return null;
});
const renderNotification = ( const renderNotification = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
image: HTMLImageElement, image: HTMLImageElement,
@@ -73,50 +54,100 @@ const renderNotification = (
} }
}; };
const notificationStack = computed(() => { const mainNotification = computed(() => ({
const stack: Array<{ id: string; image: HTMLImageElement }> = [ image: settingsImage!,
{ id: "main", image: settingsImage! }, title: $t("settings.title"),
]; description: $t("settings.description"),
}));
if (activeMenu.value) { const menuNotification = computed(() => {
stack.push({ id: activeMenu.value.id, image: activeMenu.value.image! }); if (!store.currentMenu) return null;
let image: HTMLImageElement | null = null;
let id = "";
if (/^options[A-Z]/.test(store.currentMenu)) {
image = optionsImage!;
id = "options";
} else if (/^clock[A-Z]/.test(store.currentMenu)) {
image = clockImage!;
id = "clock";
} else if (/^user[A-Z]/.test(store.currentMenu)) {
image = userImage!;
id = "user";
} else if (/^touchScreen[A-Z]/.test(store.currentMenu)) {
image = touchScreenImage!;
id = "touchScreen";
} }
for (const view of store.navigationStack) { if (!image) return null;
const menuMatch = view.match(/^(options|clock|user|touchScreen)(.+)$/);
if (menuMatch) {
const [, menu, submenu] = menuMatch;
const submenuKey = submenu!.charAt(0).toLowerCase() + submenu!.slice(1);
const imageMap: Record<string, HTMLImageElement> = {
optionsStartUp: startUpImage!,
optionsLanguage: languageImage!,
optionsGbaMode: gbaModeImage!,
};
if (imageMap[view]) {
stack.push({ id: `${menu}.${submenuKey}`, image: imageMap[view]! });
}
}
}
return stack; return {
image,
title: $t(`settings.${id}.title`),
description: $t(`settings.${id}.description`),
};
});
const IMAGES_MAP: Record<string, HTMLImageElement> = {
optionsStartUp: startUpImage!,
optionsLanguage: languageImage!,
optionsGbaMode: gbaModeImage!,
};
const submenuNotification = computed(() => {
if (!store.currentSubMenu) return null;
const image = IMAGES_MAP[store.currentSubMenu];
if (!image) return null;
const menuMatch = store.currentSubMenu.match(
/^(options|clock|user|touchScreen)(.+)$/,
);
if (!menuMatch) return null;
const [, menu, submenu] = menuMatch;
const submenuKey = submenu!.charAt(0).toLowerCase() + submenu!.slice(1);
return {
image,
title: $t(`settings.${menu}.${submenuKey}.title`),
description: $t(`settings.${menu}.${submenuKey}.description`),
};
}); });
useRender((ctx) => { useRender((ctx) => {
const stackSize = notificationStack.value.length; let count = 1;
if (menuNotification.value) count++;
if (submenuNotification.value) count++;
ctx.translate(0, 144 - (stackSize - 1) * 16); ctx.translate(0, 144 - (count - 1) * 16);
for (let i = 0; i < stackSize; i++) { renderNotification(
const notification = notificationStack.value[i]!; ctx,
mainNotification.value.image,
mainNotification.value.title,
mainNotification.value.description,
);
if (menuNotification.value) {
ctx.translate(0, 16);
renderNotification( renderNotification(
ctx, ctx,
notification.image, menuNotification.value.image,
$t(`settings.${notification.id}.title`), menuNotification.value.title,
$t(`settings.${notification.id}.description`), menuNotification.value.description,
); );
}
if (submenuNotification.value) {
ctx.translate(0, 16); ctx.translate(0, 16);
renderNotification(
ctx,
submenuNotification.value.image,
submenuNotification.value.title,
submenuNotification.value.description,
);
} }
}); });

View File

@@ -1,33 +1,49 @@
import gsap from "gsap"; import gsap from "gsap";
export const useMenuAnimation = (isOpen: Ref<boolean>) => { export const useMenuAnimation = (key: string, isOpen: Ref<boolean>) => {
const animation = reactive({ const animation = useState(`animation-${key}`, () => ({
playing: false, playing: false,
stage1Offset: 48, stage1Offset: 48,
stage2Offset: 48, stage2Offset: 48,
}); }));
watch(isOpen, (current, previous) => { watch(isOpen, (current, previous) => {
const duration = 0.1; const duration = 0.1;
const timeline = gsap.timeline({ const timeline = gsap.timeline({
onStart: () => { onStart: () => {
animation.playing = true; animation.value.playing = true;
}, },
onComplete: () => { onComplete: () => {
animation.playing = false; animation.value.playing = false;
}, },
}); });
if (current === true && previous === false) { if (current === true && previous === false) {
timeline timeline
.fromTo(animation, { stage1Offset: 48 }, { stage1Offset: 0, duration }) .fromTo(
.fromTo(animation, { stage2Offset: 48 }, { stage2Offset: 0, duration }); animation.value,
{ stage1Offset: 48 },
{ stage1Offset: 0, duration },
)
.fromTo(
animation.value,
{ stage2Offset: 48 },
{ stage2Offset: 0, duration },
);
} else if (current === false && previous === true) { } else if (current === false && previous === true) {
timeline timeline
.fromTo(animation, { stage2Offset: 0 }, { stage2Offset: 48, duration }) .fromTo(
.fromTo(animation, { stage1Offset: 0 }, { stage1Offset: 48, duration }); animation.value,
{ stage2Offset: 0 },
{ stage2Offset: 48, duration },
)
.fromTo(
animation.value,
{ stage1Offset: 0 },
{ stage1Offset: 48, duration },
);
} }
}); });
return animation; return animation.value;
}; };

View File

@@ -1,37 +1,28 @@
export const useSettingsStore = defineStore("settings", { export const useSettingsStore = defineStore("settings", {
state: () => ({ state: () => ({
activeMenu: null as string | null, currentMenu: null as string | null,
navigationStack: [] as string[], currentSubMenu: null as string | null,
}), }),
getters: { getters: {
isMenuOpen: (state) => (menu: string) => { isMenuOpen: (state) => (menu: string) => {
if (!state.activeMenu) return false; if (!state.currentMenu) return false;
return new RegExp(`^${menu}[A-Z]`).test(state.activeMenu); return new RegExp(`^${menu}[A-Z]`).test(state.currentMenu);
}, },
isAnyOtherMenuOpen: (state) => (excludeMenu: string) => { isAnyOtherMenuOpen: (state) => (excludeMenu: string) => {
if (!state.activeMenu) return false; if (!state.currentMenu) return false;
return ["options", "clock", "user", "touchScreen"] return ["options", "clock", "user", "touchScreen"]
.filter((m) => m !== excludeMenu) .filter((m) => m !== excludeMenu)
.some((m) => new RegExp(`^${m}[A-Z]`).test(state.activeMenu!)); .some((m) => new RegExp(`^${m}[A-Z]`).test(state.currentMenu!));
},
currentView: (state) => {
if (state.navigationStack.length === 0) return "menu";
return state.navigationStack[state.navigationStack.length - 1];
}, },
}, },
actions: { actions: {
setActiveMenu(menu: string | null) { setActiveMenu(menu: string | null) {
this.activeMenu = menu; this.currentMenu = menu;
}, },
pushNavigation(view: string) { setCurrentSubMenu(submenu: string | null) {
this.navigationStack.push(view); this.currentSubMenu = submenu;
},
popNavigation() {
this.navigationStack.pop();
}, },
}, },
}); });

View File

@@ -1,9 +1,7 @@
{ {
"settings": { "settings": {
"main": { "title": "Settings",
"title": "Settings", "description": "Change system settings here. Select\nthe settings you'd like to change.",
"description": "Change system settings here. Select\nthe settings you'd like to change."
},
"options": { "options": {
"title": "Options", "title": "Options",