feat(home): intro + outro animations

This commit is contained in:
2025-11-16 20:19:47 +01:00
parent 0c5490d4ee
commit 10584db979
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">
const store = useHomeStore();
const backgroundImage = useTemplateRef("backgroundImage");
useRender((ctx) => {
if (!backgroundImage.value) return;
ctx.globalAlpha = store.intro.stage1Opacity;
ctx.drawImage(backgroundImage.value, 0, 0);
});
</script>

View File

@@ -1,10 +1,17 @@
<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 PictochatButton from "./PictochatButton.vue";
import DownloadPlayButton from "./DownloadPlayButton.vue";
import Selector from "./Selector.vue";
const store = useHomeStore();
const BUTTONS_CONFIG = {
game: [31, 23, 193, 49],
pictochat: [31, 71, 97, 49],
@@ -13,29 +20,11 @@ const BUTTONS_CONFIG = {
type ButtonType = keyof typeof BUTTONS_CONFIG;
const animationPercentage = ref(0);
const { selectedButton, selectorPosition } = useButtonNavigation({
buttons: BUTTONS_CONFIG,
initialButton: "game",
onButtonClick: (buttonName) => {
if (animationPercentage.value > 0) return;
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}`);
}
},
},
);
onButtonClick: () => {
store.animateOutro();
},
navigation: {
game: {
@@ -56,28 +45,32 @@ const { selectedButton, selectorPosition } = useButtonNavigation({
});
const getButtonOffset = (button: ButtonType) => {
if (selectedButton.value === button) return animationPercentage.value * -200;
if (selectedButton.value === button) return store.outro.buttonOffsetY;
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>
<template>
<GameButton
:x="33"
:y="25 + getButtonOffset('game')"
:opacity="getOpacity()"
:opacity="getOpacity('game')"
/>
<DownloadPlayButton
:x="128"
:y="72 + getButtonOffset('downloadPlay')"
:opacity="getOpacity()"
:opacity="getOpacity('downloadPlay')"
/>
<PictochatButton
:x="32"
:y="72 + getButtonOffset('pictochat')"
:opacity="getOpacity()"
:opacity="getOpacity('pictochat')"
/>
<Selector

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
// NOTE: calendar background is handled by TopScreenBackground
const store = useHomeStore();
const lastRowImage = useTemplateRef("lastRowImage");
const daySelectorImage = useTemplateRef("daySelectorImage");
const calendarImage = useTemplateRef("calendarImage");
useRender((ctx) => {
if (!lastRowImage.value || !daySelectorImage.value) return;
if (!calendarImage.value || !lastRowImage.value || !daySelectorImage.value)
return;
ctx.fillStyle = "black";
ctx.font = "7px NDS7";
@@ -24,11 +27,19 @@ useRender((ctx) => {
const firstDay = new Date(year, month, 1).getDay();
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;
if (extraRow) {
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 row = 0; row < CALENDAR_COLS; row += 1) {
const cellIndex = col * CALENDAR_COLS + row;
@@ -70,6 +81,11 @@ useRender((ctx) => {
</script>
<template>
<img
ref="calendarImage"
src="/assets/images/home/top-screen/calendar/calendar.png"
hidden
/>
<img
ref="lastRowImage"
src="/assets/images/home/top-screen/calendar/last-row.png"

View File

@@ -2,6 +2,10 @@
const CENTER_X = 63;
const CENTER_Y = 95;
const store = useHomeStore();
const clockImage = useTemplateRef("clockImage");
function drawLine(
ctx: CanvasRenderingContext2D,
x0: number,
@@ -48,6 +52,16 @@ function drawLine(
}
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 renderHand = (
@@ -75,8 +89,8 @@ useRender((ctx) => {
ctx.fillStyle = "#494949";
ctx.fillRect(CENTER_X - 2, CENTER_Y - 2, 5, 5);
});
defineOptions({
render: () => null,
});
</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">
const store = useHomeStore();
const statusBarImage = useTemplateRef("statusBarImage");
const gbaDisplayImage = useTemplateRef("gbaDisplayImage");
const startupModeImage = useTemplateRef("startupModeImage");
const batteryImage = useTemplateRef("batteryImage");
// TODO: don't call it here
callOnce("intro", () => store.animateIntro());
useRender((ctx) => {
if (!gbaDisplayImage.value || !startupModeImage.value || !batteryImage.value)
if (
!statusBarImage.value ||
!gbaDisplayImage.value ||
!startupModeImage.value ||
!batteryImage.value
)
return;
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.font = "7px NDS7";
@@ -44,6 +60,11 @@ useRender((ctx) => {
</script>
<template>
<img
ref="statusBarImage"
src="/assets/images/home/top-screen/status-bar/status-bar.png"
hidden
/>
<img
ref="gbaDisplayImage"
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,
},
);
},
},
});