feat(nds): intro animation

This commit is contained in:
2026-01-07 22:17:37 +01:00
parent b2c7dc5131
commit ec9f8cc264
3 changed files with 201 additions and 85 deletions

View File

@@ -1,6 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { useLoop, useTresContext } from "@tresjs/core"; import { useLoop, useTresContext } from "@tresjs/core";
import * as THREE from "three"; import * as THREE from "three";
import gsap from "gsap";
const INTRO_ANIMATION = {
MODEL_SPIN_DURATION: 3,
LID_OPEN_DURATION: 2,
LID_OPEN_OVERLAP: 1.5,
CAMERA_START_POSITION: new THREE.Vector3(0, 15, 2.6),
CAMERA_START_ROTATION: new THREE.Euler(THREE.MathUtils.degToRad(-90), 0, 0),
CAMERA_END_POSITION: new THREE.Vector3(0, 14, 10),
CAMERA_END_ROTATION: new THREE.Euler(THREE.MathUtils.degToRad(-55), 0, 0),
CAMERA_DURATION: 3,
LID_CLOSED_ROTATION: THREE.MathUtils.degToRad(120),
LID_OPENED_ROTATION: THREE.MathUtils.degToRad(-30),
};
const props = defineProps<{ const props = defineProps<{
topScreenCanvas: HTMLCanvasElement | null; topScreenCanvas: HTMLCanvasElement | null;
@@ -8,6 +24,7 @@ const props = defineProps<{
}>(); }>();
const { assets } = useAssets(); const { assets } = useAssets();
const app = useAppStore();
const model = assets.nintendoDs.scene.clone(true); const model = assets.nintendoDs.scene.clone(true);
@@ -18,6 +35,7 @@ let bottomScreenTexture: THREE.CanvasTexture | null = null;
// screens // screens
const TOP_SCREEN = "Object_9"; const TOP_SCREEN = "Object_9";
const BOTTOM_SCREEN = "Object_28"; const BOTTOM_SCREEN = "Object_28";
const LID = "Object_8";
// buttons // buttons
const CROSS_BUTTON = "Object_21"; const CROSS_BUTTON = "Object_21";
@@ -41,11 +59,20 @@ const { camera, renderer } = useTresContext();
model.scale.set(100, 100, 100); model.scale.set(100, 100, 100);
const app = useAppStore();
watch( watch(
() => camera.activeCamera.value, () => camera.activeCamera.value,
(cam) => { (cam) => {
if (cam) app.setCamera(cam); if (cam) {
app.setCamera(cam);
if (app.booted) {
cam.position.copy(INTRO_ANIMATION.CAMERA_END_POSITION);
cam.rotation.copy(INTRO_ANIMATION.CAMERA_END_ROTATION);
} else {
cam.position.copy(INTRO_ANIMATION.CAMERA_START_POSITION);
cam.rotation.copy(INTRO_ANIMATION.CAMERA_START_ROTATION);
}
}
}, },
{ immediate: true }, { immediate: true },
); );
@@ -57,6 +84,80 @@ model.traverse((child) => {
} }
}); });
const lidMesh = requireMesh(LID);
const topScreenMesh = requireMesh(TOP_SCREEN);
if (app.booted) {
lidMesh.rotation.x = INTRO_ANIMATION.LID_OPENED_ROTATION;
topScreenMesh.rotation.x = INTRO_ANIMATION.LID_OPENED_ROTATION;
} else {
lidMesh.rotation.x = INTRO_ANIMATION.LID_CLOSED_ROTATION;
topScreenMesh.rotation.x = INTRO_ANIMATION.LID_CLOSED_ROTATION;
}
const hasAnimated = ref(app.booted);
const animateIntro = () => {
if (hasAnimated.value) return;
hasAnimated.value = true;
const introTimeline = gsap.timeline();
introTimeline.to(
camera.activeCamera.value.position,
{
x: INTRO_ANIMATION.CAMERA_END_POSITION.x,
y: INTRO_ANIMATION.CAMERA_END_POSITION.y,
z: INTRO_ANIMATION.CAMERA_END_POSITION.z,
duration: INTRO_ANIMATION.CAMERA_DURATION,
ease: "power2.inOut",
},
0,
);
introTimeline.to(
camera.activeCamera.value.rotation,
{
x: INTRO_ANIMATION.CAMERA_END_ROTATION.x,
y: INTRO_ANIMATION.CAMERA_END_ROTATION.y,
z: INTRO_ANIMATION.CAMERA_END_ROTATION.z,
duration: INTRO_ANIMATION.CAMERA_DURATION,
ease: "power2.inOut",
},
0,
);
introTimeline.to(
model.rotation,
{
y: Math.PI * 2,
duration: INTRO_ANIMATION.MODEL_SPIN_DURATION,
ease: "power2.inOut",
},
0,
);
introTimeline.to(
lidMesh.rotation,
{
x: INTRO_ANIMATION.LID_OPENED_ROTATION,
duration: INTRO_ANIMATION.LID_OPEN_DURATION,
ease: "power2.out",
},
INTRO_ANIMATION.MODEL_SPIN_DURATION - INTRO_ANIMATION.LID_OPEN_OVERLAP,
);
introTimeline.to(
topScreenMesh.rotation,
{
x: INTRO_ANIMATION.LID_OPENED_ROTATION,
duration: INTRO_ANIMATION.LID_OPEN_DURATION,
ease: "power2.out",
},
"<",
);
};
watch( watch(
() => [props.topScreenCanvas, props.bottomScreenCanvas], () => [props.topScreenCanvas, props.bottomScreenCanvas],
() => { () => {
@@ -189,6 +290,11 @@ const pressButton = (button: string) => {
}; };
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
if (!hasAnimated.value) {
animateIntro();
return;
}
const domElement = renderer.instance.domElement; const domElement = renderer.instance.domElement;
const rect = domElement.getBoundingClientRect(); const rect = domElement.getBoundingClientRect();

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import * as THREE from "three";
import type { Screen as NDSScreen } from "#components"; import type { Screen as NDSScreen } from "#components";
const ENABLE_3D = true; const ENABLE_3D = true;
@@ -56,11 +55,7 @@ useKeyUp((key) => {
<LoadingScreen v-if="!isReady" /> <LoadingScreen v-if="!isReady" />
<div v-else> <div v-else>
<TresCanvas v-if="ENABLE_3D" window-size clear-color="#181818"> <TresCanvas v-if="ENABLE_3D" window-size clear-color="#181818">
<TresPerspectiveCamera <TresPerspectiveCamera :args="[45, 1, 0.001, 1000]" />
:args="[45, 1, 0.001, 1000]"
:position="[0, 10, 12]"
:rotation="[THREE.MathUtils.degToRad(-38.3), 0, 0]"
/>
<TresAmbientLight /> <TresAmbientLight />
<TresDirectionalLight /> <TresDirectionalLight />

View File

@@ -1,6 +1,19 @@
import gsap from "gsap"; import gsap from "gsap";
import * as THREE from "three"; import * as THREE from "three";
const ANIMATION = {
NDS_CAMERA_POSITION: new THREE.Vector3(0, 14, 10),
NDS_CAMERA_ROTATION: new THREE.Euler(THREE.MathUtils.degToRad(-55), 0, 0),
GALLERY_CAMERA_POSITION: new THREE.Vector3(0, 4.5, -3),
GALLERY_CAMERA_ROTATION: new THREE.Euler(THREE.MathUtils.degToRad(-62), 0, 0),
CAMERA_DURATION: 3,
CAMERA_ROTATION_OVERLAP: 0.1,
FADE_DURATION: 1,
FADE_CAMERA_OVERLAP: 0.9,
NAVIGATE_DELAY: 3150,
};
export const useGalleryStore = defineStore("gallery", { export const useGalleryStore = defineStore("gallery", {
state: () => ({ state: () => ({
intro: { intro: {
@@ -18,93 +31,40 @@ export const useGalleryStore = defineStore("gallery", {
actions: { actions: {
animateIntro() { animateIntro() {
const CAMERA_DELAY = 0.7;
const CAMERA_DURATION = 3;
const FADE_DELAY = 0;
const FADE_DURATION = 1;
this.isIntro = true; this.isIntro = true;
this.isOutro = false; this.isOutro = false;
const app = useAppStore(); const app = useAppStore();
if (app.camera) {
gsap.fromTo(
app.camera.position,
{
x: 0,
y: 10,
z: 12,
},
{
x: 0,
y: 3,
z: -1.7,
duration: CAMERA_DURATION,
delay: CAMERA_DELAY,
ease: "power2.inOut",
},
);
gsap.fromTo( // Intro: Fade starts first (at 0), camera starts after with overlap
app.camera.rotation, const cameraDelay =
{ ANIMATION.FADE_DURATION - ANIMATION.FADE_CAMERA_OVERLAP;
x: THREE.MathUtils.degToRad(-38.3),
y: 0,
z: 0,
},
{
x: THREE.MathUtils.degToRad(-25),
y: 0,
z: 0,
duration: CAMERA_DURATION * 0.9,
delay: CAMERA_DELAY,
ease: "power2.inOut",
},
);
}
gsap.fromTo( gsap.fromTo(
this.intro, this.intro,
{ fadeOpacity: 0 }, { fadeOpacity: 0 },
{ {
fadeOpacity: 1, fadeOpacity: 1,
duration: FADE_DURATION, duration: ANIMATION.FADE_DURATION,
delay: FADE_DELAY, delay: 0,
ease: "none", ease: "none",
}, },
); );
setTimeout(() => {
navigateTo("/gallery");
}, 3150);
},
animateOutro() {
const CAMERA_DELAY = 0;
const CAMERA_DURATION = 3;
const FADE_DELAY = 2.3;
const FADE_DURATION = 1;
this.isIntro = false;
this.isOutro = true;
const app = useAppStore();
if (app.camera) { if (app.camera) {
gsap.fromTo( gsap.fromTo(
app.camera.position, app.camera.position,
{ {
x: 0, x: ANIMATION.NDS_CAMERA_POSITION.x,
y: 3, y: ANIMATION.NDS_CAMERA_POSITION.y,
z: -1.7, z: ANIMATION.NDS_CAMERA_POSITION.z,
}, },
{ {
x: 0, x: ANIMATION.GALLERY_CAMERA_POSITION.x,
y: 10, y: ANIMATION.GALLERY_CAMERA_POSITION.y,
z: 12, z: ANIMATION.GALLERY_CAMERA_POSITION.z,
duration: CAMERA_DURATION, duration: ANIMATION.CAMERA_DURATION,
delay: CAMERA_DELAY, delay: cameraDelay,
ease: "power2.inOut", ease: "power2.inOut",
}, },
); );
@@ -112,16 +72,71 @@ export const useGalleryStore = defineStore("gallery", {
gsap.fromTo( gsap.fromTo(
app.camera.rotation, app.camera.rotation,
{ {
x: THREE.MathUtils.degToRad(-25), x: ANIMATION.NDS_CAMERA_ROTATION.x,
y: 0, y: ANIMATION.NDS_CAMERA_ROTATION.y,
z: 0, z: ANIMATION.NDS_CAMERA_ROTATION.z,
}, },
{ {
x: THREE.MathUtils.degToRad(-38.3), x: ANIMATION.GALLERY_CAMERA_ROTATION.x,
y: 0, y: ANIMATION.GALLERY_CAMERA_ROTATION.y,
z: 0, z: ANIMATION.GALLERY_CAMERA_ROTATION.z,
duration: CAMERA_DURATION * 0.9, duration:
delay: CAMERA_DELAY, ANIMATION.CAMERA_DURATION *
(1 - ANIMATION.CAMERA_ROTATION_OVERLAP),
delay: cameraDelay,
ease: "power2.inOut",
},
);
}
setTimeout(() => {
navigateTo("/gallery");
}, ANIMATION.NAVIGATE_DELAY);
},
animateOutro() {
this.isIntro = false;
this.isOutro = true;
const app = useAppStore();
// Outro: Camera starts first (at 0), fade starts after with overlap
const fadeDelay =
ANIMATION.CAMERA_DURATION - ANIMATION.FADE_CAMERA_OVERLAP;
if (app.camera) {
gsap.fromTo(
app.camera.position,
{
x: ANIMATION.GALLERY_CAMERA_POSITION.x,
y: ANIMATION.GALLERY_CAMERA_POSITION.y,
z: ANIMATION.GALLERY_CAMERA_POSITION.z,
},
{
x: ANIMATION.NDS_CAMERA_POSITION.x,
y: ANIMATION.NDS_CAMERA_POSITION.y,
z: ANIMATION.NDS_CAMERA_POSITION.z,
duration: ANIMATION.CAMERA_DURATION,
delay: 0,
ease: "power2.inOut",
},
);
gsap.fromTo(
app.camera.rotation,
{
x: ANIMATION.GALLERY_CAMERA_ROTATION.x,
y: ANIMATION.GALLERY_CAMERA_ROTATION.y,
z: ANIMATION.GALLERY_CAMERA_ROTATION.z,
},
{
x: ANIMATION.NDS_CAMERA_ROTATION.x,
y: ANIMATION.NDS_CAMERA_ROTATION.y,
z: ANIMATION.NDS_CAMERA_ROTATION.z,
duration:
ANIMATION.CAMERA_DURATION *
(1 - ANIMATION.CAMERA_ROTATION_OVERLAP),
delay: 0,
ease: "power2.inOut", ease: "power2.inOut",
}, },
); );
@@ -132,8 +147,8 @@ export const useGalleryStore = defineStore("gallery", {
{ fadeOpacity: 1 }, { fadeOpacity: 1 },
{ {
fadeOpacity: 0, fadeOpacity: 0,
duration: FADE_DURATION, duration: ANIMATION.FADE_DURATION,
delay: FADE_DELAY, delay: fadeDelay,
ease: "none", ease: "none",
}, },
); );
@@ -141,7 +156,7 @@ export const useGalleryStore = defineStore("gallery", {
setTimeout(() => { setTimeout(() => {
this.isOutro = false; this.isOutro = false;
app.navigateTo("home"); app.navigateTo("home");
}, 3310); }, ANIMATION.NAVIGATE_DELAY);
}, },
}, },
}); });