Commit 03f08106 by zhuxichen

xx

parent a8d321bc
...@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'fs'; ...@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'fs';
export default function customLoaderPlugin() { export default function customLoaderPlugin() {
return { return {
name: 'custom-loader-plugin', name: 'custom-loader-plugin',
transform(source, id) { transform(source: string, id: string) {
// 替换路径1 // 替换路径1
const newPath = id.replace(/\/engines\/(.+?)\//, '/'); const newPath = id.replace(/\/engines\/(.+?)\//, '/');
if (existsSync(newPath)) { if (existsSync(newPath)) {
......
...@@ -16,7 +16,10 @@ ...@@ -16,7 +16,10 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@tweenjs/tween.js": "^25.0.0",
"@types/three": "^0.171.0",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"three": "^0.171.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
}, },
...@@ -58,6 +61,7 @@ ...@@ -58,6 +61,7 @@
"unplugin-auto-import": "^0.18.6", "unplugin-auto-import": "^0.18.6",
"unplugin-vue-components": "^0.27.5", "unplugin-vue-components": "^0.27.5",
"vite": "^6.0.1", "vite": "^6.0.1",
"vite-plugin-prerender": "^1.0.8",
"vite-plugin-vue-devtools": "^7.6.5", "vite-plugin-vue-devtools": "^7.6.5",
"vitest": "^2.1.5", "vitest": "^2.1.5",
"vue-tsc": "^2.1.10" "vue-tsc": "^2.1.10"
......
// postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
This source diff could not be displayed because it is too large. You can view the blob instead.
/* src/style.css */
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
...@@ -12,6 +12,7 @@ import { updateThemeVariable } from '@/plugins/switchTheme'; ...@@ -12,6 +12,7 @@ import { updateThemeVariable } from '@/plugins/switchTheme';
<div class="vvv_color">测试主题stylus使用 $parimary-color</div> <div class="vvv_color">测试主题stylus使用 $parimary-color</div>
<div class="vvv_color2">测试主题less使用 @warning-color</div> <div class="vvv_color2">测试主题less使用 @warning-color</div>
<button @click="updateThemeVariable('primary-color', '#ff0000')">点击改变默认主题色</button> <button @click="updateThemeVariable('primary-color', '#ff0000')">点击改变默认主题色</button>
<my-web-component name="Vue User"></my-web-component>
<WelcomeItem> <WelcomeItem>
<template #icon> <template #icon>
<DocumentationIcon /> <DocumentationIcon />
......
...@@ -5,6 +5,7 @@ import { createApp } from 'vue'; ...@@ -5,6 +5,7 @@ import { createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import { initializeTheme } from './plugins/switchTheme'; import { initializeTheme } from './plugins/switchTheme';
import './assets/my-web-component.es.js';
const app = createApp(App); const app = createApp(App);
// 读取默认主题模式 // 读取默认主题模式
......
<template> <template>
<div class="about"> <div class="vk relative">
<h1>This is an about page</h1> <div class="absolute right-0 top-0 z-10 w-full h-full text-white" @click="changeSize">
改变大小{{ canvasSize }}
</div>
<PraticleEffect
imageUrl="/v_logo.png"
:canvasSize="900"
:perspective="500"
:particleSize="0.9"
:depthRange="depthRange"
:imageSize="canvasSize"
:particleStep="2"
:openChangeAnimation="true"
/>
</div> </div>
</template> </template>
<style> <script setup lang="ts">
@media (min-width: 1024px) { import PraticleEffect from './Component/PraticleEffect.vue';
.about { const canvasSize = ref(450);
min-height: 100vh; const depthRange = ref(50);
display: flex; const changeSize = () => {
align-items: center; canvasSize.value = canvasSize.value === 450 ? 200 : 300;
} depthRange.value = depthRange.value === 30 ? 50 : 30;
};
</script>
<style scoped>
.vk {
width: 100vw;
height: 100vh;
} }
</style> </style>
<template>
<div class="particle-container">
<canvas ref="canvasRef"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import * as THREE from 'three';
// 参数配置
const particleCount = 1000; // 粒子数量
const maxRange = 1000; // 粒子生成的坐标范围
const particleSize = 5; // 粒子大小
const rotationSpeed = 0.0005; // 背景粒子旋转速度
const layerDepth = [-500, -600, -700]; // Z轴上不同平面的深度
const canvasRef = ref<HTMLCanvasElement | null>(null);
let scene: THREE.Scene, camera: THREE.PerspectiveCamera, renderer: THREE.WebGLRenderer;
let xBgPoints: THREE.Points, yBgPoints: THREE.Points, zBgPoints: THREE.Points;
let animationId: number;
let pointTexture: THREE.Texture;
// 初始化场景
function initScene() {
const canvas = canvasRef.value;
if (!canvas) return;
// 场景
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.0005); // 添加雾化效果
scene.background = new THREE.Color(0x0c0c18);
// 相机
camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 1, 3000);
camera.position.z = 1000;
// 渲染器
renderer = new THREE.WebGLRenderer({ canvas, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
// 纹理加载
const loader = new THREE.TextureLoader();
pointTexture = loader.load('/gradient.png'); // 替换为您自己的纹理路径
// 初始化粒子背景
initBackgroundPoints();
}
// 随机坐标生成
function randomPosition(min: number, max: number) {
return Math.random() * (max - min) + min;
}
// 初始化背景粒子
function initBackgroundPoints() {
const geometry = new THREE.BufferGeometry();
const positions = [];
const colors = [];
for (let i = 0; i < particleCount; i++) {
positions.push(
randomPosition(-maxRange, maxRange),
randomPosition(-maxRange, maxRange),
randomPosition(-maxRange, maxRange),
);
colors.push(1, 1, 1); // 白色
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: particleSize,
map: pointTexture,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
vertexColors: true,
});
// 创建三层粒子
xBgPoints = new THREE.Points(geometry, material);
xBgPoints.position.z = layerDepth[0];
yBgPoints = new THREE.Points(geometry, material);
yBgPoints.position.z = layerDepth[1];
zBgPoints = new THREE.Points(geometry, material);
zBgPoints.position.z = layerDepth[2];
scene.add(xBgPoints, yBgPoints, zBgPoints);
}
// 动画渲染逻辑
function animate() {
animationId = requestAnimationFrame(animate);
// 旋转粒子背景
xBgPoints.rotation.y += rotationSpeed;
yBgPoints.rotation.y -= rotationSpeed;
zBgPoints.rotation.z += rotationSpeed / 2;
renderer.render(scene, camera);
}
// 处理窗口大小变化
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// 生命周期管理
onMounted(() => {
initScene();
animate();
window.addEventListener('resize', onResize);
});
onUnmounted(() => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', onResize);
});
</script>
<style scoped>
.particle-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #000;
}
</style>
<template>
<div class="particle-container">
<canvas ref="canvasRef"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import * as THREE from 'three';
// 参数配置
const pointCount = 800; // 粒子数量
const particleSize = 5; // 粒子大小
const maxRange = 1000; // 粒子生成范围
const rotationSpeed = 0.001; // 旋转速度
const canvasRef = ref<HTMLCanvasElement | null>(null);
let renderer: THREE.WebGLRenderer;
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let animationId: number;
let xBgPoints: THREE.Points, yBgPoints: THREE.Points, zBgPoints: THREE.Points;
// 初始化场景
function initScene() {
const canvas = canvasRef.value;
if (!canvas) return;
const width = window.innerWidth;
const height = window.innerHeight;
// 创建场景
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
// 创建相机
camera = new THREE.PerspectiveCamera(40, width / height, 1, 3000);
camera.position.z = 1000;
// 创建渲染器
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
// 创建粒子背景
initBackgroundPoints();
}
// 随机生成坐标
function randomPosition(min: number, max: number) {
return Math.random() * (max - min) + min;
}
// 初始化背景粒子
function initBackgroundPoints() {
const geometry = new THREE.BufferGeometry();
const positions = [];
for (let i = 0; i < pointCount; i++) {
positions.push(
randomPosition(-maxRange, maxRange),
randomPosition(-maxRange, maxRange),
randomPosition(-maxRange, maxRange),
);
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
size: particleSize,
color: new THREE.Color(0x00aaff), // 纯色粒子
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
});
// 创建三层粒子系统
xBgPoints = new THREE.Points(geometry, material);
xBgPoints.position.z = -0.5 * maxRange;
yBgPoints = new THREE.Points(geometry, material);
yBgPoints.position.z = -0.6 * maxRange;
zBgPoints = new THREE.Points(geometry, material);
zBgPoints.position.z = -0.7 * maxRange;
scene.add(xBgPoints);
scene.add(yBgPoints);
scene.add(zBgPoints);
}
// 动画循环
function animate() {
animationId = requestAnimationFrame(animate);
// 添加旋转效果
if (xBgPoints && yBgPoints && zBgPoints) {
xBgPoints.rotation.y += rotationSpeed;
yBgPoints.rotation.y += -rotationSpeed;
zBgPoints.rotation.z += rotationSpeed / 2;
}
renderer.render(scene, camera);
}
// 窗口大小自适应
function onResize() {
const width = window.innerWidth;
const height = window.innerHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
// 生命周期钩子
onMounted(() => {
initScene();
animate();
window.addEventListener('resize', onResize);
});
onUnmounted(() => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', onResize);
});
</script>
<style scoped>
.particle-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #000;
}
</style>
<template>
<div class="particle-logo-container" :style="{ backgroundColor: backgroundColor }">
<canvas ref="canvas" class="particle-canvas"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
/**
* 粒子类,用于存储粒子相关属性。
* 非响应式,避免 Vue 的响应式性能开销。
*/
class Particle {
// 粒子的初始位置(原点坐标)
public originalX: number;
public originalY: number;
public originalZ: number;
// 粒子的当前坐标
public currentX: number;
public currentY: number;
public currentZ: number;
// 粒子的散开目标坐标(可选)
public scatterX?: number;
public scatterY?: number;
public scatterZ?: number;
// 粒子的透明度、大小以及是否为边缘粒子
public alpha: number;
public size: number;
public isEdge: boolean;
constructor(oX: number, oY: number, oZ: number, size: number, isEdge: boolean) {
this.originalX = oX;
this.originalY = oY;
this.originalZ = oZ;
this.currentX = oX;
this.currentY = oY;
this.currentZ = oZ;
this.alpha = 1;
this.size = size;
this.isEdge = isEdge;
}
}
// 定义组件的 Props
const props = defineProps({
imageUrl: {
type: String,
required: true, // 必须提供图片路径
},
canvasSize: {
type: Number,
default: 600, // 画布的宽高,单位像素
},
imageSize: {
type: Number,
default: null, // 默认为 null 表示自动适配 canvas 的大小 (width/2)
},
perspective: {
type: Number,
default: 400, // 透视深度,越大越平面,这个平面是整效果上的平面
},
particleSize: {
type: Number,
default: 1.2, // 粒子的基础大小
},
depthRange: {
type: Number,
default: 50, // 默认 Z 轴范围为 -50 到 +50
},
backgroundColor: {
type: String,
default: 'black', // 背景颜色
},
particleColor: {
type: String,
default: 'rgba(255, 255, 255, 1)', // 普通粒子颜色
},
edgeColor: {
type: String,
default: 'rgba(255, 255, 255, 1)', // 边缘粒子颜色
},
rotationDuration: {
type: Number,
default: 15000, // 粒子一圈旋转的时间,单位毫秒
},
particleStep: {
type: Number,
default: 2, // 粒子稀疏度,值越大,粒子越少
},
advancedEdgeDetection: {
type: Boolean,
default: true, // 是否启用高级边缘检测
},
edgeAlphaThreshold: {
type: Number,
default: 200, // 边缘透明度阈值,用于判断边缘粒子
},
mouseMoveEffect: {
type: Boolean,
default: false, // 是否启用鼠标移动效果
},
mouseSensitivity: {
type: Number,
default: 0.002, // 鼠标移动的灵敏度
},
mouseMoveMode: {
type: String,
default: 'canvas', // 鼠标移动的影响范围(整个画布或整体窗口)
},
openChangeAnimation: {
type: Boolean,
default: false, // 是否开启粒子散开动画
},
transitionTotalDuration: {
type: Number,
default: 1500, // 过渡时间,单位毫秒
},
});
// Canvas 引用,用于获取 DOM 元素
const canvas = ref<HTMLCanvasElement | null>(null);
// 动画帧 ID,用于取消动画
let animationId: number | null = null;
// 粒子数组,用于存储当前的粒子
let particles: Particle[] = [];
let oldParticles: Particle[] = []; // 旧粒子(散开效果用)
let newParticles: Particle[] = []; // 新粒子(聚合效果用)
// 过渡状态
let transitionInProgress = false; // 是否正在过渡
let transitionProgress = 0; // 过渡的进度
// 是否允许旋转
let rotationEnabled = true;
// 鼠标位置
let mouseX = 0;
let mouseY = 0;
// 清理画布和动画帧
const clearCanvas = () => {
if (canvas.value) {
const ctx = canvas.value.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
}
}
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
};
// ---------------------- 边缘检测 ----------------------
const isEdgeSimple = (alpha: number, threshold: number) => alpha > threshold;
function isEdgeAdvanced(
x: number,
y: number,
imgWidth: number,
imgHeight: number,
imageData: Uint8ClampedArray,
step: number,
) {
const index = (y * imgWidth + x) * 4 + 3;
const alpha = imageData[index];
const checkOffset = (dx: number, dy: number) => {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || nx >= imgWidth || ny < 0 || ny >= imgHeight) return false;
const neighborAlpha = imageData[(ny * imgWidth + nx) * 4 + 3];
return Math.abs(alpha - neighborAlpha) > 50;
};
return checkOffset(step, 0) || checkOffset(0, step);
}
// ---------------------- 初始化 ----------------------
function initParticleEffect() {
clearCanvas();
const canvasEl = canvas.value;
if (!canvasEl) return;
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
const width = props.canvasSize;
const height = props.canvasSize;
canvasEl.width = width;
canvasEl.height = height;
const logoImage = new Image();
logoImage.src = props.imageUrl;
logoImage.onload = () => {
// 绘制 Logo 到画布
const maxLogoWidth = width / 2;
const imgWidth = props.imageSize ? props.imageSize : maxLogoWidth;
// 保持图片宽高比
const imgHeight = (logoImage.height / logoImage.width) * imgWidth;
// 绘制图片
ctx.drawImage(logoImage, (width - imgWidth) / 2, (height - imgHeight) / 2, imgWidth, imgHeight);
// 获取像素
const imageData = ctx.getImageData(
(width - imgWidth) / 2,
(height - imgHeight) / 2,
imgWidth,
imgHeight,
).data;
const step = props.particleStep;
const freshParticles: Particle[] = [];
// 遍历图片像素,生成粒子
for (let y = 0; y < imgHeight; y += step) {
for (let x = 0; x < imgWidth; x += step) {
const alpha = imageData[(y * imgWidth + x) * 4 + 3];
if (alpha > 50) {
// 如果像素透明度大于 50,则认为是边缘像素
let isEdgeParticle = false;
if (props.advancedEdgeDetection) {
// 如果启用高级边缘检测,则使用 isEdgeAdvanced 函数判断是否为边缘像素
isEdgeParticle = isEdgeAdvanced(x, y, imgWidth, imgHeight, imageData, step);
} else {
isEdgeParticle = isEdgeSimple(alpha, props.edgeAlphaThreshold);
}
// 计算粒子的原点坐标
const oX = x - imgWidth / 2;
const oY = y - imgHeight / 2;
// 使用 depthRange 控制 Z 坐标范围
const oZ = Math.random() * props.depthRange - props.depthRange / 2;
// 生成粒子
freshParticles.push(new Particle(oX, oY, oZ, props.particleSize, isEdgeParticle));
}
}
}
if (particles.length > 0 && props.openChangeAnimation) {
// 已存在 => 散开过渡
oldParticles = particles;
newParticles = freshParticles;
startScatterTransition();
} else {
// 第一次
particles = freshParticles;
startRenderLoop();
}
};
logoImage.onerror = () => {
console.error('Failed to load imageUrl:', logoImage.src);
};
}
// ---------------------- 散开并禁用旋转 ----------------------
function startScatterTransition() {
transitionInProgress = true;
transitionProgress = 0;
// 过渡期间禁用旋转
rotationEnabled = false;
// 给旧粒子散开坐标
oldParticles.forEach((p) => {
const angle = Math.random() * Math.PI * 2;
const radius = 200 + Math.random() * 200;
const randomZ = 80 + Math.random() * 120;
// 计算粒子的散开坐标
p.scatterX = p.currentX + Math.cos(angle) * radius;
p.scatterY = p.currentY + Math.sin(angle) * radius;
p.scatterZ = p.currentZ + (Math.random() > 0.5 ? randomZ : -randomZ);
p.alpha = 1;
});
// 给新粒子散开起点
newParticles.forEach((p) => {
const angle = Math.random() * Math.PI * 2;
const radius = 200 + Math.random() * 200;
const randomZ = 80 + Math.random() * 120;
// 计算粒子的散开坐标
p.scatterX = p.currentX + Math.cos(angle) * radius;
p.scatterY = p.currentY + Math.sin(angle) * radius;
p.scatterZ = p.currentZ + (Math.random() > 0.5 ? randomZ : -randomZ);
// 立刻把当前坐标移动到散开处
p.currentX = p.scatterX;
p.currentY = p.scatterY;
p.currentZ = p.scatterZ;
p.alpha = 0;
});
startRenderLoop();
}
// ---------------------- 渲染循环 ----------------------
function startRenderLoop() {
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
const canvasEl = canvas.value;
if (!canvasEl) return;
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
const width = canvasEl.width;
const height = canvasEl.height;
const centerX = width / 2;
const centerY = height / 2;
let rotationY = 0;
const baseRotationSpeed = (2 * Math.PI) / props.rotationDuration;
let lastTimestamp: number | null = null;
// ==== 缓动函数 (easeInOutQuad) ====
function easeInOutQuad(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
const renderFrame = (timestamp: number) => {
if (!lastTimestamp) lastTimestamp = timestamp;
const deltaTime = timestamp - lastTimestamp;
lastTimestamp = timestamp;
// 1) 每帧都先清理画布, 避免残影
ctx.clearRect(0, 0, width, height);
// 2) 若 rotationEnabled,就更新旋转
if (rotationEnabled) {
rotationY += baseRotationSpeed * deltaTime;
if (rotationY >= 2 * Math.PI) rotationY -= 2 * Math.PI;
}
// 也可加判断:若不启用旋转,则 rotationY 不变
// 3) 鼠标水平带来的额外旋转(可选)
const rotationOffsetY = rotationEnabled ? mouseX * props.mouseSensitivity : 0;
const finalRotationY = rotationY + rotationOffsetY;
const cosY = Math.cos(finalRotationY);
const sinY = Math.sin(finalRotationY);
const perspective = props.perspective;
// 4) 如果在散开/聚合过渡中
if (transitionInProgress) {
transitionProgress += deltaTime;
let linearT = transitionProgress / props.transitionTotalDuration;
if (linearT > 1) linearT = 1;
// ==== 使用 easeInOutQuad 缓动 ====
const t = easeInOutQuad(linearT);
// 定义散开与聚合的分段
const half = 0.5;
// 旧粒子:前半散开 / 后半淡出
oldParticles.forEach((p) => {
if (p.scatterX == null) return;
if (t <= half) {
const scatterProgress = t / half;
// 计算粒子的当前坐标
p.currentX = lerp(p.currentX, p.scatterX, scatterProgress);
p.currentY = lerp(p.currentY, p.scatterY!, scatterProgress);
p.currentZ = lerp(p.currentZ, p.scatterZ!, scatterProgress);
} else {
// 后半段淡出
const fadeOut = (t - half) / (1 - half);
p.alpha = 1 - fadeOut;
}
});
// 新粒子:前半保持散开起点,后半聚合回 original
newParticles.forEach((p) => {
if (p.scatterX == null) return;
if (t <= half) {
// 慢慢显现
p.alpha = t * 0.5;
} else {
const gatherProgress = (t - half) / (1 - half);
// 计算粒子的当前坐标
p.currentX = lerp(p.scatterX, p.originalX, gatherProgress);
p.currentY = lerp(p.scatterY!, p.originalY, gatherProgress);
p.currentZ = lerp(p.scatterZ!, p.originalZ, gatherProgress);
p.alpha = gatherProgress;
}
});
// 如果动画结束
if (linearT >= 1) {
transitionInProgress = false;
oldParticles = [];
particles = newParticles;
newParticles = [];
// 恢复旋转
rotationEnabled = true;
}
}
// 5) 绘制 old + particles + new
drawParticles(ctx, oldParticles, width, height, centerX, centerY, cosY, sinY, perspective);
drawParticles(ctx, particles, width, height, centerX, centerY, cosY, sinY, perspective);
drawParticles(ctx, newParticles, width, height, centerX, centerY, cosY, sinY, perspective);
// 6) 循环
animationId = requestAnimationFrame(renderFrame);
};
animationId = requestAnimationFrame(renderFrame);
}
/** 绘制粒子 */
function drawParticles(
ctx: CanvasRenderingContext2D,
list: Particle[],
width: number,
height: number,
centerX: number,
centerY: number,
cosY: number,
sinY: number,
perspective: number,
) {
for (const p of list) {
if (p.alpha <= 0) continue; // 透明则跳过
// 做 3D 旋转 + 透视投影
const tempX = p.currentX * cosY - p.currentZ * sinY;
const tempZ = p.currentX * sinY + p.currentZ * cosY;
const scale = perspective / (perspective - tempZ);
const screenX = centerX + tempX * scale;
const screenY = centerY + p.currentY * scale;
// 绘制粒子
ctx.beginPath();
// 绘制圆形
ctx.arc(screenX, screenY, p.size * scale, 0, Math.PI * 2);
// 设置颜色
ctx.fillStyle = p.isEdge ? props.edgeColor : props.particleColor;
// 设置透明度
ctx.globalAlpha = p.alpha;
// 填充
ctx.fill();
}
// 恢复透明度
ctx.globalAlpha = 1;
}
/** 简单的线性插值 */
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
// ---------------------- 鼠标移动 ----------------------
function handleMouseMove(e: MouseEvent) {
// 获取鼠标位置
const canvasEl = canvas.value;
if (!canvasEl) return;
const rect = canvasEl.getBoundingClientRect();
const width = canvasEl.width;
const height = canvasEl.height;
const cx = rect.left + width / 2;
const cy = rect.top + height / 2;
mouseX = e.clientX - cx;
mouseY = e.clientY - cy;
}
const openMouseMoveEffect = () => {
// 开启鼠标移动效果
if (props.mouseMoveEffect) {
if (props.mouseMoveMode === 'overall') {
// 监听整个窗口的鼠标移动
window.addEventListener('mousemove', handleMouseMove);
} else {
const canvasEl = canvas.value;
if (canvasEl) {
canvasEl.addEventListener('mousemove', handleMouseMove);
}
}
}
};
const closeMouseMoveEffect = () => {
// 关闭鼠标移动效果
if (props.mouseMoveEffect) {
if (props.mouseMoveMode === 'overall') {
window.removeEventListener('mousemove', handleMouseMove);
} else {
const canvasEl = canvas.value;
if (canvasEl) {
canvasEl.removeEventListener('mousemove', handleMouseMove);
}
}
}
};
defineExpose({
openMouseMoveEffect,
closeMouseMoveEffect,
});
// 挂载时初始化粒子效果并开启鼠标移动监听
onMounted(() => {
initParticleEffect();
openMouseMoveEffect();
});
// 组件卸载前清理画布和事件监听
onBeforeUnmount(() => {
clearCanvas();
closeMouseMoveEffect();
});
// 控制初始化标识
let isInitialized = ref(false);
watchEffect(() => {
// 监听 props 的变化,重新初始化粒子效果,使用_式解构命名避免触发ts报错
const {
imageUrl: _imageUrl,
canvasSize: _canvasSize,
imageSize: _imageSize,
perspective: _perspective,
particleSize: _particleSize,
particleColor: _particleColor,
edgeColor: _edgeColor,
rotationDuration: _rotationDuration,
particleStep: _particleStep,
advancedEdgeDetection: _advancedEdgeDetection,
edgeAlphaThreshold: _edgeAlphaThreshold,
depthRange: _depthRange,
} = props;
if (isInitialized.value) {
// 属性变化时触发的逻辑
initParticleEffect(); // 重新初始化
} else {
// 首次运行,不触发散射特效,仅完成标记
isInitialized.value = true;
}
});
</script>
<style scoped>
/* 容器样式 */
.particle-logo-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.particle-canvas {
display: block;
width: 100%;
height: 100%;
/* 确保 canvas 在高分屏下显示清晰 */
image-rendering: crisp-edges;
}
</style>
<template>
<div
class="starfield-container"
:style="{
backgroundImage: computedBgImage,
backgroundColor: backgroundType === 'color' ? backgroundColor : 'transparent',
}"
>
<canvas ref="canvasRef"></canvas>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
/**
* 粒子数量,决定星空的密集程度
*/
particleCount: {
type: Number as PropType<number>,
default: 200,
},
/**
* 粒子飞行速度,数值越大速度越快
*/
speed: {
type: Number as PropType<number>,
default: 1.5,
},
/**
* 焦距 (Field of View),影响透视效果的强弱
*/
fov: {
type: Number as PropType<number>,
default: 300,
},
/**
* 粒子距离观察者的最大深度
*/
maxDepth: {
type: Number as PropType<number>,
default: 2000,
},
/**
* 粒子距离观察者的最小深度,避免粒子太靠近导致异常放大
*/
minDepth: {
type: Number as PropType<number>,
default: 250,
},
/**
* 粒子显示的图像路径,如果为空则使用默认渐变圆点
*/
imageSrc: {
type: String as PropType<string>,
default: '',
},
/**
* 背景类型,可选值为:
* - 'color': 使用纯色背景
* - 'transparent': 使用透明背景
* - 'image': 使用图片背景
*/
backgroundType: {
type: String as PropType<'color' | 'transparent' | 'image'>,
default: 'color',
validator: (value: string) => ['color', 'transparent', 'image'].includes(value),
},
/**
* 纯色背景颜色,仅当 backgroundType 为 'color' 时生效
*/
backgroundColor: {
type: String as PropType<string>,
default: 'black',
},
/**
* 背景图片路径,仅当 backgroundType 为 'image' 时生效
*/
backgroundImage: {
type: String as PropType<string>,
default: '',
},
/**
* 粒子色值
*/
particleColor: {
type: String as PropType<string>,
default: 'rgba(255, 255, 255, 0.5)',
},
});
interface Particle {
x: number;
y: number;
z: number;
}
const canvasRef = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
let particles: Particle[] = [];
let animationFrameId: number | null = null;
const width = ref(0);
const height = ref(0);
// 动态计算背景图片样式
const computedBgImage = computed(() =>
props.backgroundType === 'image' && props.backgroundImage
? `url(${props.backgroundImage})`
: 'none',
);
// 初始化粒子
const initParticles = () => {
particles = Array.from({ length: props.particleCount }, () => ({
x: (Math.random() - 0.5) * width.value * 2,
y: (Math.random() - 0.5) * height.value * 2,
z: Math.random() * (props.maxDepth - props.minDepth) + props.minDepth,
}));
};
// 动画绘制逻辑
const draw = () => {
if (!ctx) return;
ctx.clearRect(0, 0, width.value, height.value);
for (const particle of particles) {
particle.z -= props.speed;
if (particle.z <= 0) {
particle.z = Math.random() * (props.maxDepth - props.minDepth) + props.minDepth;
}
const sx = width.value / 2 + (particle.x / particle.z) * props.fov;
const sy = height.value / 2 + (particle.y / particle.z) * props.fov;
const size = (1 - particle.z / props.maxDepth) * 5;
ctx.beginPath();
ctx.arc(sx, sy, size, 0, Math.PI * 2);
ctx.fillStyle = props.particleColor;
ctx.fill();
}
animationFrameId = requestAnimationFrame(draw);
};
// 初始化画布
const resizeCanvas = () => {
if (!canvasRef.value) return;
canvasRef.value.width = canvasRef.value.offsetWidth;
canvasRef.value.height = canvasRef.value.offsetHeight;
width.value = canvasRef.value.width;
height.value = canvasRef.value.height;
initParticles();
};
onMounted(() => {
const canvas = canvasRef.value;
if (!canvas) return;
ctx = canvas.getContext('2d');
resizeCanvas();
draw();
window.addEventListener('resize', resizeCanvas);
});
onBeforeUnmount(() => {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
window.removeEventListener('resize', resizeCanvas);
});
</script>
<style scoped>
.starfield-container {
width: 100%;
height: 100%;
overflow: hidden;
background-size: cover;
background-position: center;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
</style>
<template>
<div ref="container" class="particle-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import * as THREE from 'three';
// 定义Props
const props = defineProps({
particleCount: { type: Number, default: 10000 }, // 每层粒子数量
layerCount: { type: Number, default: 4 }, // 层数
baseRadius: { type: Number, default: 200 }, // 基础圆环半径
waveAmplitude: { type: Number, default: 18 }, // 波动振幅
frequency: { type: Number, default: 6 }, // 波动频率(花瓣数)
particleSize: { type: Number, default: 1 }, // 粒子大小
rotationSpeed: { type: Number, default: 0.0005 }, // 整体旋转速度
waveSpeed: { type: Number, default: 0.08 }, // 波动速度
backgroundColor: { type: String, default: 'black' }, // 背景颜色
colors: {
type: Array as () => string[],
default: () => [
'hsl(180, 100%, 60%)',
'hsl(190, 100%, 60%)',
'hsl(200, 100%, 60%)',
'hsl(210, 100%, 60%)',
],
}, // 颜色数组 ,推荐使用hls数据,也可以使用rgb数据
});
// 元素容器和Three.js变量
const container = ref<HTMLDivElement | null>(null);
let scene: THREE.Scene, camera: THREE.PerspectiveCamera, renderer: THREE.WebGLRenderer;
let waveParticles: THREE.Points[] = [];
let animationId: number;
let time = 0; // 动画时间
// 初始化场景、相机和渲染器
function initScene() {
if (!container.value) return;
// 清理旧场景
waveParticles = [];
if (scene) {
scene.clear();
renderer.dispose();
}
scene = new THREE.Scene();
scene.background = new THREE.Color(props.backgroundColor);
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 2000);
camera.position.z = 500;
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
container.value.appendChild(renderer.domElement);
createWaveLayers();
animate();
}
// 创建波动层
function createWaveLayers() {
for (let i = 0; i < props.layerCount; i++) {
const geometry = new THREE.BufferGeometry();
const positions = [];
const sizes = [];
for (let j = 0; j < props.particleCount; j++) {
const angle = (j / props.particleCount) * Math.PI * 2; // 均匀分布在圆周上
const offset = Math.sin(angle * props.frequency + i * 1.5) * props.waveAmplitude; // 花瓣波动
const radius = props.baseRadius + offset; // 叠加振幅
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
const z = (Math.random() - 0.5) * 30; // Z轴增加颗粒感
positions.push(x, y, z);
sizes.push(Math.random() * 2 + 1); // 粒子大小随机
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
const color = props.colors[i % props.colors.length];
const material = new THREE.PointsMaterial({
size: props.particleSize,
// color: new THREE.Color(`hsl(${180 + i * 10}, 100%, 60%)`), // 层次颜色变化
color: new THREE.Color(color),
transparent: true,
opacity: 0.8 - i * 0.2,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const points = new THREE.Points(geometry, material);
waveParticles.push(points);
scene.add(points);
}
}
// 动画逻辑
function animate() {
animationId = requestAnimationFrame(animate);
time += props.waveSpeed;
waveParticles.forEach((points, index) => {
const positions = points.geometry.attributes.position.array;
for (let i = 0; i < positions.length; i += 3) {
const angle = Math.atan2(positions[i + 1], positions[i]);
const baseRadius =
props.baseRadius + Math.sin(angle * props.frequency + index * 1.5) * props.waveAmplitude;
// 在初始位置基础上平滑地波动
const offset = Math.sin(angle * props.frequency + time + index * 0.5) * props.waveAmplitude;
positions[i] = Math.cos(angle) * (baseRadius + offset); // X轴更新
positions[i + 1] = Math.sin(angle) * (baseRadius + offset); // Y轴更新
}
points.geometry.attributes.position.needsUpdate = true;
// 控制每一层的旋转效果
points.rotation.z += props.rotationSpeed * (index % 2 === 0 ? 1 : -1);
});
renderer.render(scene, camera);
}
// 处理窗口大小变化
function onResize() {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
}
// 监听Props变化,重新渲染
watch(props, () => {
initScene();
});
// 生命周期管理
onMounted(() => {
initScene();
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', onResize);
renderer.dispose();
scene.clear();
});
</script>
<style scoped>
.particle-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
import { Config } from 'tailwindcss' import { Config } from 'tailwindcss';
function generateSizes(scale = 4) {
const sizes = {};
for (let i = 1; i <= 1000; i++) {
sizes[i] = `${i * scale}px`; // 动态生成规则,例如 w-100 = 400px
}
return sizes;
}
const config: Config = { const config: Config = {
content: [ content: [
'./index.html', './index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}', // 扫描的文件路径 './src/**/*.{vue,js,ts,jsx,tsx}', // 扫描的文件路径
], ],
theme: { theme: {
extend: {}, // 自定义扩展 extend: {
width: generateSizes(4), // 设置宽度,每单位 = scale * px
height: generateSizes(4), // 设置高度,每单位 = scale * px
}, // 自定义扩展
}, },
plugins: [], plugins: [],
} };
export default config export default config;
...@@ -39,6 +39,8 @@ export default defineConfig(async ({ mode }) => { ...@@ -39,6 +39,8 @@ export default defineConfig(async ({ mode }) => {
return { return {
plugins: [ plugins: [
vue(), vue(),
// customLoaderPlugin(),
Components({ Components({
resolvers: [AntDesignVueResolver()], resolvers: [AntDesignVueResolver()],
dts: 'src/components.d.ts', dts: 'src/components.d.ts',
...@@ -48,9 +50,9 @@ export default defineConfig(async ({ mode }) => { ...@@ -48,9 +50,9 @@ export default defineConfig(async ({ mode }) => {
resolvers: [AntDesignVueResolver()], resolvers: [AntDesignVueResolver()],
dts: 'src/auto-imports.d.ts', dts: 'src/auto-imports.d.ts',
}), }),
customLoaderPlugin(),
], ],
css: { css: {
postcss: './postcss.config.js',
preprocessorOptions: { preprocessorOptions: {
less: { less: {
javascriptEnabled: true, javascriptEnabled: true,
......
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url';
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config' import { mergeConfig, defineConfig, configDefaults } from 'vitest/config';
import viteConfig from './vite.config' import viteConfig from './vite.config';
export default mergeConfig( export default mergeConfig(
// @ts-ignore
viteConfig, viteConfig,
defineConfig({ defineConfig({
test: { test: {
...@@ -11,4 +12,4 @@ export default mergeConfig( ...@@ -11,4 +12,4 @@ export default mergeConfig(
root: fileURLToPath(new URL('./', import.meta.url)), root: fileURLToPath(new URL('./', import.meta.url)),
}, },
}), }),
) );
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment