add heart animation

This commit is contained in:
2025-08-02 09:35:30 +04:00
parent a5232864d2
commit faa4663e20
21 changed files with 423 additions and 31 deletions

View File

@ -3,8 +3,9 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<link href="/src/assets/main.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>I LOVE YOU</title>
</head>
<body>
<div id="app"></div>

77
package-lock.json generated
View File

@ -17,6 +17,8 @@
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^24.1.0",
"@types/three": "^0.178.1",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
@ -540,6 +542,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
@ -1875,6 +1884,13 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/draco3d": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
@ -1894,12 +1910,52 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.8.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.178.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.178.1.tgz",
"integrity": "sha512-WSabew1mgWgRx2RfLfKY+9h4wyg6U94JfLbZEGU245j/WY2kXqU0MUfghS+3AYMV5ET1VlILAgpy77cB6a3Itw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/three/node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.22.tgz",
@ -2515,6 +2571,13 @@
}
}
},
"node_modules/@webgpu/types": {
"version": "0.1.64",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.64.tgz",
"integrity": "sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -4503,6 +4566,13 @@
"node": ">= 8"
}
},
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -5544,6 +5614,13 @@
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",

View File

@ -21,6 +21,8 @@
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^24.1.0",
"@types/three": "^0.178.1",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",

View File

@ -1,27 +1,10 @@
<script setup lang="ts"></script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<Default>
<RouterView />
</Default>
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>
<script setup lang="ts">
import Default from './layout/UiDefault.vue'
import { RouterView } from 'vue-router'
</script>

View File

@ -0,0 +1,48 @@
<template>
<component
:is="tag"
class="font-semibold text-twilight-900"
:class="[
{
'text-3xl md:text-4xl lg:text-5xl 2xl:text-6xl': size === '600',
}, // H1
{
'text-3xl md:text-4xl 2xl:text-5xl': size === '500',
}, // H2
{
'text-2xl md:text-3xl 2xl:text-4xl': size === '400',
}, // H3
{
'text-xl lg:text-2xl 2xl:text-3xl': size === '300',
}, // H4
{
'text-lg md:text-xl': size === '200',
}, // H5
]"
>
<slot />
</component>
</template>
<script setup lang="ts">
import { toRefs } from 'vue'
const props = defineProps({
/**
* Tag name: h1 to h6
*/
tag: {
type: String,
default: 'h2',
},
/**
* Heading size: '600' (largest) to '200' (smallest)
*/
size: {
type: String,
default: '500',
},
})
const { size, tag } = toRefs(props)
</script>

View File

@ -0,0 +1,45 @@
<template>
<component
:is="as"
class="text-gray-900"
:class="[
{
'text-2xl lg:text-3xl 2xl:text-4xl': size === '600',
}, // 24px
{
'text-xl lg:text-2xl 2xl:text-3xl': size === '500',
}, // 20px
{
'text-lg lg:text-xl 2xl:text-2xl': size === '400',
}, // 18px
{
'text-base lg:text-lg': size === '300',
}, // 16px
{
'text-sm': size === '200',
}, // 14px
]"
>
<slot />
</component>
</template>
<script setup lang="ts">
import { toRefs } from 'vue'
const props = defineProps({
as: {
type: String,
default: 'p',
},
/**
* Tailwind text size from 600 to 200
*/
size: {
type: String,
default: '300',
},
})
const { size, as } = toRefs(props)
</script>

View File

@ -0,0 +1,5 @@
export default {
SCALE: 1,
DIRECTION: 1,
ANIMATION_ID: 0,
}

1
src/constants/index.ts Normal file
View File

@ -0,0 +1 @@
export { default as heartSectionConst } from './heart-section.ts'

66
src/declarations.d.ts vendored Normal file
View File

@ -0,0 +1,66 @@
declare module 'three/examples/jsm/loaders/GLTFLoader' {
import { Loader, LoadingManager, Group, AnimationClip, Object3D } from 'three'
export interface GLTF {
scene: Group
scenes: Group[]
animations: AnimationClip[]
cameras: Object3D[]
asset: Record<string, unknown>
}
export class GLTFLoader extends Loader {
constructor(manager?: LoadingManager)
load(
url: string,
onLoad: (gltf: GLTF) => void,
onProgress?: (event: ProgressEvent<EventTarget>) => void,
onError?: (event: ErrorEvent | unknown) => void
): void
}
}
declare module 'three/examples/jsm/controls/OrbitControls' {
import { Camera, EventDispatcher, MOUSE, TOUCH, Vector3 } from 'three'
export class OrbitControls extends EventDispatcher {
constructor(object: Camera, domElement?: HTMLElement)
object: Camera
target: Vector3
update(): boolean
dispose(): void
enabled: boolean
autoRotate: boolean
autoRotateSpeed: number
minDistance: number
maxDistance: number
minZoom: number
maxZoom: number
minPolarAngle: number
maxPolarAngle: number
minAzimuthAngle: number
maxAzimuthAngle: number
enableDamping: boolean
dampingFactor: number
enableZoom: boolean
zoomSpeed: number
enableRotate: boolean
rotateSpeed: number
enablePan: boolean
panSpeed: number
screenSpacePanning: boolean
keyPanSpeed: number
mouseButtons: { LEFT: MOUSE; MIDDLE: MOUSE; RIGHT: MOUSE }
touches: { ONE: TOUCH; TWO: TOUCH }
}
}

17
src/layout/UiDefault.vue Normal file
View File

@ -0,0 +1,17 @@
<template>
<div class="min-h-screen">
<Header></Header>
<Main class="min-h-screen h-full">
<Suspense>
<slot />
</Suspense>
</Main>
<Footer></Footer>
</div>
</template>
<script setup lang="ts">
import Header from '../layout/UiHeader.vue'
import Main from '../layout/UiMain.vue'
import Footer from '../layout/UiFooter.vue'
</script>

7
src/layout/UiFooter.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<footer>
<div class="flex items-center justify-between hidden">hidden</div>
</footer>
</template>
<script setup lang="ts"></script>

7
src/layout/UiHeader.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<header>
<div class="flex items-center justify-between hidden">hidden</div>
</header>
</template>
<script setup lang="ts"></script>

8
src/layout/UiMain.vue Normal file
View File

@ -0,0 +1,8 @@
<template>
<main>
<Suspense>
<slot />
</Suspense>
</main>
</template>
<script setup lang="ts"></script>

View File

@ -1,5 +1,8 @@
import { createApp } from 'vue'
import '../src/assets/main.css'
import App from './App.vue'
import router from './router'
createApp(App).mount('#app')
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,88 @@
<template>
<section>
<div ref="container" class="w-full h-screen" />
</section>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { heartSectionConst } from '../../../constants'
const container = ref<HTMLDivElement | null>(null)
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let renderer: THREE.WebGLRenderer
let controls: OrbitControls
let heart: THREE.Group | null = null
let { SCALE: scale, ANIMATION_ID: animationId, DIRECTION: direction } = heartSectionConst
const animate = () => {
animationId = requestAnimationFrame(animate)
if (heart) {
scale += 0.003 * direction
if (scale > 1.05 || scale < 0.95) return (direction *= -1)
heart.scale.set(scale, scale, scale)
heart.rotation.y += 0.005
}
controls.update()
renderer.render(scene, camera)
}
onMounted(() => {
if (!container.value) return
scene = new THREE.Scene()
scene.background = new THREE.Color(0x000000)
camera = new THREE.PerspectiveCamera(
75,
container.value.clientWidth / container.value.clientHeight,
0.1,
1000,
)
camera.position.set(0, 1, 5)
camera.position.set(0, 1, 5)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(container.value.clientWidth, container.value.clientHeight)
container.value.appendChild(renderer.domElement)
const ambientLight = new THREE.AmbientLight(0xffffff, 3)
scene.add(ambientLight)
const pointLight = new THREE.PointLight(0xff3366, 1.2)
pointLight.position.set(5, 5, 5)
scene.add(pointLight)
controls = new OrbitControls(camera, renderer.domElement)
const loader = new GLTFLoader()
loader.load('/models/heart.glb', (gltf) => {
heart = gltf.scene
heart?.scale.set(1, 1, 1)
scene.add(heart)
})
window.addEventListener('resize', onWindowResize)
animate()
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationId)
window.removeEventListener('resize', onWindowResize)
})
const onWindowResize = () => {
if (!container.value) return
camera.aspect = container.value.clientWidth / container.value.clientHeight
camera.updateProjectionMatrix()
renderer.setSize(container.value.clientWidth, container.value.clientHeight)
}
</script>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import HeartSection from './_ui/heartSection.vue'
</script>
<template>
<section>
<HeartSection />
</section>
</template>

View File

@ -0,0 +1,7 @@
import type { Component } from 'vue'
export type TRoute = {
path: string
name: string
component: () => Promise<Component>
}

View File

@ -0,0 +1,10 @@
import type { TRoute } from './_type/TRoutes.ts'
const routes: TRoute[] = [
{
path: '/',
name: '/',
component: () => import('../../pages/index/rootPage.vue'),
},
]
export default routes

8
src/router/index.ts Normal file
View File

@ -0,0 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import routes from './config/routes.ts'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
export default router

View File

@ -1,7 +1,11 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["./src/**/*", "./src/**/*.vue"],
"exclude": ["./src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"@/*": ["./src/*"],
"~": ["../src/*"],
/* Linting */
"strict": true,
@ -11,8 +15,4 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"paths": {
"@/*": ["./src/*"]
}
}