feat(settings/user/color): intro and outro animation

This commit is contained in:
2026-02-05 22:42:39 +01:00
parent 4eed175e69
commit 04a5a36305
2 changed files with 180 additions and 27 deletions

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import gsap from "gsap";
const { onRender, onClick } = useScreen();
const app = useAppStore();
@@ -14,6 +16,114 @@ const CELL_SIZE = 16;
const SPACING = 16;
const ANIMATION_SPEED = 475;
const PALETTE_SLIDE_OFFSET = 96;
const PALETTE_SLIDE_DURATION = 0.25;
const CELL_ANIMATION_DURATION = 0.04;
const CELL_ANIMATION_STAGGER = 0.02;
const SNAIL_ORDER = [0, 1, 2, 3, 7, 11, 15, 14, 13, 12, 8, 4, 5, 6, 10, 9];
// animation state
const animation = reactive({
cellVisibility: Array(16).fill(0) as number[],
paletteOffsetY: PALETTE_SLIDE_OFFSET,
paletteOpacity: 0,
textOpacity: 0,
isIntro: true,
isOutro: false,
});
const isAnimating = computed(() => animation.isIntro || animation.isOutro);
const animateIntro = (): gsap.core.Timeline => {
animation.isIntro = true;
const timeline = gsap
.timeline({
onComplete: () => {
animation.isIntro = false;
},
})
.to(
animation,
{ paletteOffsetY: 0, duration: PALETTE_SLIDE_DURATION, ease: "none" },
0,
)
.to(
animation,
{ paletteOpacity: 1, duration: PALETTE_SLIDE_DURATION, ease: "none" },
0,
);
const colorsDuration =
SNAIL_ORDER.length * CELL_ANIMATION_STAGGER + CELL_ANIMATION_DURATION;
timeline.to(
animation,
{ textOpacity: 1, duration: colorsDuration * 0.5, ease: "none" },
PALETTE_SLIDE_DURATION + colorsDuration * 0.5,
);
for (let i = 0; i < SNAIL_ORDER.length; i++) {
timeline.to(
animation.cellVisibility,
{
[SNAIL_ORDER[i]!]: 1,
duration: CELL_ANIMATION_DURATION,
ease: "none",
},
PALETTE_SLIDE_DURATION + i * CELL_ANIMATION_STAGGER,
);
}
return timeline;
};
const animateOutro = (): gsap.core.Timeline => {
animation.isOutro = true;
const timeline = gsap.timeline();
const colorsOutroDuration =
SNAIL_ORDER.length * CELL_ANIMATION_STAGGER + CELL_ANIMATION_DURATION;
timeline.to(
animation,
{ textOpacity: 0, duration: colorsOutroDuration * 0.5, ease: "none" },
0,
);
for (let i = SNAIL_ORDER.length - 1; i >= 0; i--) {
timeline.to(
animation.cellVisibility,
{
[SNAIL_ORDER[i]!]: 0,
duration: CELL_ANIMATION_DURATION,
ease: "none",
},
(SNAIL_ORDER.length - 1 - i) * CELL_ANIMATION_STAGGER,
);
}
timeline.to(
animation,
{
paletteOffsetY: PALETTE_SLIDE_OFFSET,
duration: PALETTE_SLIDE_DURATION,
ease: "none",
},
colorsOutroDuration,
);
timeline.to(
animation,
{ paletteOpacity: 0, duration: PALETTE_SLIDE_DURATION, ease: "none" },
colorsOutroDuration,
);
return timeline;
};
onMounted(() => {
animateIntro();
});
const originalSelectedCol = app.color.col;
const originalSelectedRow = app.color.row;
const originalColor = app.color.hex;
@@ -30,6 +140,8 @@ const select = (col: number, row: number) => {
};
useKeyDown((key) => {
if (isAnimating.value) return;
switch (key) {
case "NDS_UP":
if (selectedRow > 0) select(selectedCol, selectedRow - 1);
@@ -47,6 +159,8 @@ useKeyDown((key) => {
});
onClick((x, y) => {
if (isAnimating.value) return;
const relativeX = x - GRID_START_X;
const relativeY = y - GRID_START_Y;
@@ -65,50 +179,86 @@ onClick((x, y) => {
});
onRender((ctx, deltaTime) => {
ctx.globalAlpha = animation.paletteOpacity;
ctx.translate(0, animation.paletteOffsetY);
assets.images.settings.bottomScreen.user.colorPalette.draw(ctx, 16, 32);
// animate
const finalSelectorX = GRID_START_X + selectedCol * (CELL_SIZE + SPACING) - 4;
const finalSelectorY = GRID_START_Y + selectedRow * (CELL_SIZE + SPACING) - 4;
// colors
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
const cellIndex = row * GRID_SIZE + col;
const visibility = animation.cellVisibility[cellIndex]!;
const dx = finalSelectorX - selectorX;
const dy = finalSelectorY - selectorY;
if (visibility > 0) {
const x = GRID_START_X + col * (CELL_SIZE + SPACING);
const y = GRID_START_Y + row * (CELL_SIZE + SPACING);
const size = Math.floor(CELL_SIZE * visibility);
const offset = Math.floor((CELL_SIZE - size) / 2);
if (dx > 0) {
selectorX += ANIMATION_SPEED * (deltaTime / 1000);
if (selectorX > finalSelectorX) selectorX = finalSelectorX;
} else if (dx < 0) {
selectorX -= ANIMATION_SPEED * (deltaTime / 1000);
if (selectorX < finalSelectorX) selectorX = finalSelectorX;
} else if (dy > 0) {
selectorY += ANIMATION_SPEED * (deltaTime / 1000);
if (selectorY > finalSelectorY) selectorY = finalSelectorY;
} else if (dy < 0) {
selectorY -= ANIMATION_SPEED * (deltaTime / 1000);
if (selectorY < finalSelectorY) selectorY = finalSelectorY;
ctx.fillStyle = APP_COLORS[row]![col]!;
ctx.fillRect(x + offset, y + offset, size, size);
}
}
}
// selector
if (!animation.isOutro) {
const finalSelectorX =
GRID_START_X + selectedCol * (CELL_SIZE + SPACING) - 4;
const finalSelectorY =
GRID_START_Y + selectedRow * (CELL_SIZE + SPACING) - 4;
const dx = finalSelectorX - selectorX;
const dy = finalSelectorY - selectorY;
if (dx > 0) {
selectorX += ANIMATION_SPEED * (deltaTime / 1000);
if (selectorX > finalSelectorX) selectorX = finalSelectorX;
} else if (dx < 0) {
selectorX -= ANIMATION_SPEED * (deltaTime / 1000);
if (selectorX < finalSelectorX) selectorX = finalSelectorX;
} else if (dy > 0) {
selectorY += ANIMATION_SPEED * (deltaTime / 1000);
if (selectorY > finalSelectorY) selectorY = finalSelectorY;
} else if (dy < 0) {
selectorY -= ANIMATION_SPEED * (deltaTime / 1000);
if (selectorY < finalSelectorY) selectorY = finalSelectorY;
}
}
ctx.fillStyle = APP_COLORS[selectedRow]![selectedCol]!;
const selectorDrawY = selectorY;
const offsets = [0, 3, 7, 11, 15, 19, 22];
for (const offset of offsets) {
ctx.fillRect(selectorX + offset, selectorY + 0, 2, 1);
ctx.fillRect(selectorX + offset, selectorY + 23, 2, 1);
ctx.fillRect(selectorX + 0, selectorY + offset, 1, 2);
ctx.fillRect(selectorX + 23, selectorY + offset, 1, 2);
ctx.fillRect(selectorX + offset, selectorDrawY + 0, 2, 1);
ctx.fillRect(selectorX + offset, selectorDrawY + 23, 2, 1);
ctx.fillRect(selectorX + 0, selectorDrawY + offset, 1, 2);
ctx.fillRect(selectorX + 23, selectorDrawY + offset, 1, 2);
}
// preview
ctx.fillRect(192, 96, 32, 32);
ctx.globalAlpha = animation.textOpacity;
ctx.font = "7px NDS7";
ctx.fillStyle = "#000000";
ctx.textBaseline = "top";
fillTextHCentered(ctx, "Choose color", 177, 69, 62);
});
const handleCancel = () => {
select(originalSelectedCol, originalSelectedRow);
const handleActivateB = async () => {
if (isAnimating.value) return;
app.setColor(originalSelectedCol, originalSelectedRow);
await animateOutro();
store.closeSubMenu();
};
const handleConfirm = () => {
const handleActivateA = () => {
if (isAnimating.value) return;
app.save();
if (app.color.hex !== originalColor) {
@@ -124,7 +274,10 @@ const handleConfirm = () => {
confirmationModal.open({
text: $t("settings.user.color.confirmation"),
onClosed: () => store.closeSubMenu(),
onClosed: async () => {
await animateOutro();
store.closeSubMenu();
},
timeout: 2000,
});
};
@@ -135,7 +288,7 @@ const handleConfirm = () => {
:y-offset="0"
:b-label="$t('common.cancel')"
:a-label="$t('common.confirm')"
@activate-b="handleCancel"
@activate-a="handleConfirm"
@activate-b="handleActivateB"
@activate-a="handleActivateA"
/>
</template>