feat(home): intro + outro animations

This commit is contained in:
2025-11-16 20:19:47 +01:00
parent 458f027cdb
commit 658eb02c23
18 changed files with 2764 additions and 5088 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
const store = useHomeStore();
const backgroundImage = useTemplateRef("backgroundImage"); const backgroundImage = useTemplateRef("backgroundImage");
useRender((ctx) => { useRender((ctx) => {
if (!backgroundImage.value) return; if (!backgroundImage.value) return;
ctx.globalAlpha = store.intro.stage1Opacity;
ctx.drawImage(backgroundImage.value, 0, 0); ctx.drawImage(backgroundImage.value, 0, 0);
}); });
</script> </script>

View File

@@ -1,10 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import gsap from "gsap"; // the out transition:
// clicked button goes up
// other buttons, including selector fade away, text + status bar of upper screen and clock hands fades away 2x faster
// other buttons + calendar + clock body finish fading away at the same time
// then we wait a bit, and new screen starts appearing
import GameButton from "./GameButton.vue"; import GameButton from "./GameButton.vue";
import PictochatButton from "./PictochatButton.vue"; import PictochatButton from "./PictochatButton.vue";
import DownloadPlayButton from "./DownloadPlayButton.vue"; import DownloadPlayButton from "./DownloadPlayButton.vue";
import Selector from "./Selector.vue"; import Selector from "./Selector.vue";
const store = useHomeStore();
const BUTTONS_CONFIG = { const BUTTONS_CONFIG = {
game: [31, 23, 193, 49], game: [31, 23, 193, 49],
pictochat: [31, 71, 97, 49], pictochat: [31, 71, 97, 49],
@@ -13,29 +20,11 @@ const BUTTONS_CONFIG = {
type ButtonType = keyof typeof BUTTONS_CONFIG; type ButtonType = keyof typeof BUTTONS_CONFIG;
const animationPercentage = ref(0);
const { selectedButton, selectorPosition } = useButtonNavigation({ const { selectedButton, selectorPosition } = useButtonNavigation({
buttons: BUTTONS_CONFIG, buttons: BUTTONS_CONFIG,
initialButton: "game", initialButton: "game",
onButtonClick: (buttonName) => { onButtonClick: () => {
if (animationPercentage.value > 0) return; store.animateOutro();
gsap.fromTo(
animationPercentage,
{ value: 0 },
{
value: 1,
duration: 0.35,
ease: "none",
onComplete: () => {
if (buttonName === "pictochat") {
navigateTo("/contact");
} else {
throw new Error(`Not implemented: ${buttonName}`);
}
},
},
);
}, },
navigation: { navigation: {
game: { game: {
@@ -56,28 +45,32 @@ const { selectedButton, selectorPosition } = useButtonNavigation({
}); });
const getButtonOffset = (button: ButtonType) => { const getButtonOffset = (button: ButtonType) => {
if (selectedButton.value === button) return animationPercentage.value * -200; if (selectedButton.value === button) return store.outro.buttonOffsetY;
return 0; return 0;
}; };
const getOpacity = () => (1 - animationPercentage.value) * 100; const getOpacity = (button?: ButtonType) => {
if (store.isIntro) return store.intro.stage1Opacity;
if (selectedButton.value === button) return 1;
return store.outro.stage1Opacity;
};
</script> </script>
<template> <template>
<GameButton <GameButton
:x="33" :x="33"
:y="25 + getButtonOffset('game')" :y="25 + getButtonOffset('game')"
:opacity="getOpacity()" :opacity="getOpacity('game')"
/> />
<DownloadPlayButton <DownloadPlayButton
:x="128" :x="128"
:y="72 + getButtonOffset('downloadPlay')" :y="72 + getButtonOffset('downloadPlay')"
:opacity="getOpacity()" :opacity="getOpacity('downloadPlay')"
/> />
<PictochatButton <PictochatButton
:x="32" :x="32"
:y="72 + getButtonOffset('pictochat')" :y="72 + getButtonOffset('pictochat')"
:opacity="getOpacity()" :opacity="getOpacity('pictochat')"
/> />
<Selector <Selector

View File

@@ -10,7 +10,7 @@ const buttonImage = useTemplateRef("buttonImage");
useRender((ctx) => { useRender((ctx) => {
if (!buttonImage.value) return; if (!buttonImage.value) return;
ctx.globalAlpha = props.opacity / 100; ctx.globalAlpha = props.opacity;
ctx.drawImage(buttonImage.value, props.x, props.y); ctx.drawImage(buttonImage.value, props.x, props.y);
}); });
</script> </script>

View File

@@ -10,7 +10,7 @@ const buttonImage = useTemplateRef("buttonImage");
useRender((ctx) => { useRender((ctx) => {
if (!buttonImage.value) return; if (!buttonImage.value) return;
ctx.globalAlpha = props.opacity / 100; ctx.globalAlpha = props.opacity;
ctx.drawImage(buttonImage.value, props.x, props.y); ctx.drawImage(buttonImage.value, props.x, props.y);
}); });
</script> </script>

View File

@@ -10,7 +10,7 @@ const buttonImage = useTemplateRef("buttonImage");
useRender((ctx) => { useRender((ctx) => {
if (!buttonImage.value) return; if (!buttonImage.value) return;
ctx.globalAlpha = props.opacity / 100; ctx.globalAlpha = props.opacity;
ctx.drawImage(buttonImage.value, props.x, props.y); ctx.drawImage(buttonImage.value, props.x, props.y);
}); });
</script> </script>

View File

@@ -46,7 +46,7 @@ useRender((ctx) => {
const w = Math.floor(currentWidth); const w = Math.floor(currentWidth);
const h = Math.floor(currentHeight); const h = Math.floor(currentHeight);
ctx.globalAlpha = props.opacity / 100; ctx.globalAlpha = props.opacity;
ctx.drawImage(cornerImage.value, x, y); ctx.drawImage(cornerImage.value, x, y);

View File

@@ -1,9 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
const store = useHomeStore();
const backgroundImage = useTemplateRef("backgroundImage"); const backgroundImage = useTemplateRef("backgroundImage");
useRender((ctx) => { useRender((ctx) => {
if (!backgroundImage.value) return; if (!backgroundImage.value) return;
ctx.globalAlpha = store.intro.stage1Opacity;
ctx.drawImage(backgroundImage.value, 0, 0); ctx.drawImage(backgroundImage.value, 0, 0);
}); });
</script> </script>

View File

@@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
// NOTE: calendar background is handled by TopScreenBackground // NOTE: calendar background is handled by TopScreenBackground
const store = useHomeStore();
const lastRowImage = useTemplateRef("lastRowImage"); const lastRowImage = useTemplateRef("lastRowImage");
const daySelectorImage = useTemplateRef("daySelectorImage"); const daySelectorImage = useTemplateRef("daySelectorImage");
const calendarImage = useTemplateRef("calendarImage");
useRender((ctx) => { useRender((ctx) => {
if (!lastRowImage.value || !daySelectorImage.value) return; if (!calendarImage.value || !lastRowImage.value || !daySelectorImage.value)
return;
ctx.fillStyle = "black"; ctx.fillStyle = "black";
ctx.font = "7px NDS7"; ctx.font = "7px NDS7";
@@ -24,11 +27,19 @@ useRender((ctx) => {
const firstDay = new Date(year, month, 1).getDay(); const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate(); const daysInMonth = new Date(year, month + 1, 0).getDate();
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.outro.stage1Opacity;
ctx.drawImage(calendarImage.value, CALENDAR_LEFT - 3, CALENDAR_TOP - 33);
const extraRow = CALENDAR_COLS * CALENDAR_ROWS - daysInMonth - firstDay < 0; const extraRow = CALENDAR_COLS * CALENDAR_ROWS - daysInMonth - firstDay < 0;
if (extraRow) { if (extraRow) {
ctx.drawImage(lastRowImage.value, CALENDAR_LEFT - 3, CALENDAR_TOP + 79); ctx.drawImage(lastRowImage.value, CALENDAR_LEFT - 3, CALENDAR_TOP + 79);
} }
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.outro.stage2Opacity;
for (let col = 0; col < CALENDAR_ROWS + (extraRow ? 1 : 0); col += 1) { for (let col = 0; col < CALENDAR_ROWS + (extraRow ? 1 : 0); col += 1) {
for (let row = 0; row < CALENDAR_COLS; row += 1) { for (let row = 0; row < CALENDAR_COLS; row += 1) {
const cellIndex = col * CALENDAR_COLS + row; const cellIndex = col * CALENDAR_COLS + row;
@@ -70,6 +81,11 @@ useRender((ctx) => {
</script> </script>
<template> <template>
<img
ref="calendarImage"
src="/assets/images/home/top-screen/calendar/calendar.png"
hidden
/>
<img <img
ref="lastRowImage" ref="lastRowImage"
src="/assets/images/home/top-screen/calendar/last-row.png" src="/assets/images/home/top-screen/calendar/last-row.png"

View File

@@ -2,6 +2,10 @@
const CENTER_X = 63; const CENTER_X = 63;
const CENTER_Y = 95; const CENTER_Y = 95;
const store = useHomeStore();
const clockImage = useTemplateRef("clockImage");
function drawLine( function drawLine(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
x0: number, x0: number,
@@ -48,6 +52,16 @@ function drawLine(
} }
useRender((ctx) => { useRender((ctx) => {
if (!clockImage.value) return;
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.outro.stage1Opacity;
ctx.drawImage(clockImage.value, 13, 45);
ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity
: store.outro.stage2Opacity;
const now = new Date(); const now = new Date();
const renderHand = ( const renderHand = (
@@ -75,8 +89,8 @@ useRender((ctx) => {
ctx.fillStyle = "#494949"; ctx.fillStyle = "#494949";
ctx.fillRect(CENTER_X - 2, CENTER_Y - 2, 5, 5); ctx.fillRect(CENTER_X - 2, CENTER_Y - 2, 5, 5);
}); });
defineOptions({
render: () => null,
});
</script> </script>
<template>
<img ref="clockImage" src="/assets/images/home/top-screen/clock.png" hidden />
</template>

View File

@@ -1,14 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
const store = useHomeStore();
const statusBarImage = useTemplateRef("statusBarImage");
const gbaDisplayImage = useTemplateRef("gbaDisplayImage"); const gbaDisplayImage = useTemplateRef("gbaDisplayImage");
const startupModeImage = useTemplateRef("startupModeImage"); const startupModeImage = useTemplateRef("startupModeImage");
const batteryImage = useTemplateRef("batteryImage"); const batteryImage = useTemplateRef("batteryImage");
// TODO: don't call it here
callOnce("intro", () => store.animateIntro());
useRender((ctx) => { useRender((ctx) => {
if (!gbaDisplayImage.value || !startupModeImage.value || !batteryImage.value) if (
!statusBarImage.value ||
!gbaDisplayImage.value ||
!startupModeImage.value ||
!batteryImage.value
)
return; return;
const TEXT_Y = 11; const TEXT_Y = 11;
ctx.translate(0, store.intro.statusBarY);
ctx.globalAlpha = store.outro.stage2Opacity;
ctx.drawImage(statusBarImage.value, 0, 0);
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.font = "7px NDS7"; ctx.font = "7px NDS7";
@@ -44,6 +60,11 @@ useRender((ctx) => {
</script> </script>
<template> <template>
<img
ref="statusBarImage"
src="/assets/images/home/top-screen/status-bar/status-bar.png"
hidden
/>
<img <img
ref="gbaDisplayImage" ref="gbaDisplayImage"
src="/assets/images/home/top-screen/status-bar/gba-display.png" src="/assets/images/home/top-screen/status-bar/gba-display.png"

91
app/stores/home.ts Normal file
View File

@@ -0,0 +1,91 @@
import gsap from "gsap";
export const useHomeStore = defineStore("home", {
state: () => ({
intro: {
statusBarY: -20,
stage1Opacity: 0,
},
outro: {
buttonOffsetY: 0,
stage1Opacity: 1,
stage2Opacity: 1,
},
isIntro: false,
isOutro: false,
}),
actions: {
animateIntro() {
this.isIntro = true;
const start = 0.5;
const duration = 0.5;
const delay = 0.35;
gsap.fromTo(
this.intro,
{ stage1Opacity: 0 },
{
stage1Opacity: 1,
duration,
delay: start,
ease: "none",
onComplete: () => {
this.isIntro = false;
},
},
);
gsap.fromTo(
this.intro,
{ statusBarY: -20 },
{
statusBarY: 0,
duration: duration - delay,
delay: start + delay,
ease: "none",
},
);
},
animateOutro() {
this.isOutro = true;
const duration = 0.4;
const delay = 0.08;
gsap.fromTo(
this.outro,
{
buttonOffsetY: 0,
stage1Opacity: 1,
},
{
buttonOffsetY: -200,
stage1Opacity: 0,
duration,
delay,
ease: "none",
onComplete: () => {
this.isOutro = true;
},
},
);
gsap.fromTo(
this.outro,
{
stage2Opacity: 1,
},
{
stage2Opacity: 0,
ease: "none",
duration: duration / 2 - delay,
},
);
},
},
});

View File

@@ -2,6 +2,7 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2025-07-15", compatibilityDate: "2025-07-15",
devtools: { enabled: true }, devtools: { enabled: true },
modules: ["@nuxt/eslint"], modules: ["@nuxt/eslint", "@pinia/nuxt"],
css: ["~/assets/app.css"], css: ["~/assets/app.css"],
ssr: false,
}); });

View File

@@ -12,8 +12,10 @@
"lint": "eslint --fix --cache ." "lint": "eslint --fix --cache ."
}, },
"dependencies": { "dependencies": {
"@pinia/nuxt": "0.11.3",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"nuxt": "^4.2.1", "nuxt": "^4.2.1",
"pinia": "^3.0.4",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },

7633
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff