主页:
javascript
import React, { useEffect, useRef, useState } from 'react';
import '@/assets/css/Love.less';
import { Garden } from '@/utils/GardenClasses';
// 组件属性接口
interface LoveAnimationProps {
startDate?: Date; // 可选的开始日期
messages?: { // 可自定义的文本消息
initial?: string; // 初始文字
love?: string; // 告白文字
signature?: string; // 落款
};
}
// 默认开始日期:2010年11月2日20点
const DEFAULT_START_DATE = new Date(2010, 10, 2, 20, 0, 0);
// 默认文本配置
const DEFAULT_MESSAGES = {
initial: "亲爱的,这是我们相爱在一起的时光。",
love: "爱你直到永永远远。",
signature: "-爱你的人"
};
const LoveAnimation: React.FC<LoveAnimationProps> = ({
startDate = DEFAULT_START_DATE,
messages = DEFAULT_MESSAGES
}) => {
// ========== Refs定义 ==========
const canvasRef = useRef<HTMLCanvasElement>(null); // 画布引用
const gardenRef = useRef<Garden | null>(null); // 花园实例引用
const loveHeartRef = useRef<HTMLDivElement>(null); // 心形容器
const contentRef = useRef<HTMLDivElement>(null); // 内容容器
const codeRef = useRef<HTMLDivElement>(null); // 代码区域
const wordsRef = useRef<HTMLDivElement>(null); // 文字区域
const messagesRef = useRef<HTMLDivElement>(null); // 消息区域
const loveURef = useRef<HTMLDivElement>(null); // 告白区域
const elapseClockRef = useRef<HTMLDivElement>(null); // 计时器
const errorMsgRef = useRef<HTMLDivElement>(null); // 错误信息
// ========== 状态定义 ==========
const [showMessages, setShowMessages] = useState(false); // 是否显示消息
const [showLoveU, setShowLoveU] = useState(false); // 是否显示告白
const [codeContent, setCodeContent] = useState(''); // 代码内容
const [showCursor, setShowCursor] = useState(false); // 是否显示光标
const [clearVal, setClearVal] = useState(true); // 清除标志
const clearValRef = useRef(clearVal); // 清除标志的ref
// 动画定时器存储
const animationRefs = useRef<{
intervals: NodeJS.Timeout[]; // 间隔定时器
timeouts: NodeJS.Timeout[]; // 延时定时器
}>({ intervals: [], timeouts: [] });
// 完整的代码内容(带HTML格式)
const fullCodeContent = `<br />/**
<br />*2013---02-14,
<br />*2013-02-28.
<br />*/
<br />Boy name = <span class="keyword">Mr</span> ***
<br />Girl name = <span class="keyword">Mrs</span> ***
<br /><span class="comments">// Fall in love river.</span>
<br />The boy love the girl;
<br /><span class="comments">// They love each other.</span>
<br />The girl loved the boy;
<br /><span class="comments">// AS time goes on.</span>
<br />The boy can not be separated the girl;
<br /><span class="comments">// At the same time.</span>
<br />The girl can not be separated the boy;
<br /><span class="comments">// Both wind and snow all over the sky.</span>
<br /><span class="comments">// Whether on foot or 5 kilometers.</span>
<br /><span class="keyword">The boy</span> very <span class="keyword">happy</span>;
<br /><span class="keyword">The girl</span> is also very <span class="keyword">happy</span>;
<br /><span class="comments">// Whether it is right now</span>
<br /><span class="comments">// Still in the distant future.</span>
<br />The boy has but one dream;
<br /><span class="comments">// The boy wants the girl could well have been happy.</span>
<br />I want to say:
<br />Baby, I love you forever;`;
// ========== 主要副作用 ==========
useEffect(() => {
if (!canvasRef.current || !loveHeartRef.current || !contentRef.current) return;
// 检查浏览器是否支持canvas
if (!document.createElement('canvas').getContext) {
if (errorMsgRef.current) {
errorMsgRef.current.innerHTML =
"您的浏览器不支持HTML5!<br/>推荐使用 Chrome 14+/IE 9+/Firefox 7+/Safari 4+";
}
if (codeRef.current) {
codeRef.current.style.display = "none";
}
return;
}
// 初始化画布
const gardenCanvas = canvasRef.current;
gardenCanvas.width = loveHeartRef.current.offsetWidth;
gardenCanvas.height = loveHeartRef.current.offsetHeight;
// 获取2D上下文
const ctx = gardenCanvas.getContext('2d');
if (!ctx) return;
// 设置混合模式
ctx.globalCompositeOperation = "lighter";
// 创建花园实例
gardenRef.current = new Garden(ctx, gardenCanvas);
// 调整布局
adjustLayout();
// 花园渲染循环
const renderInterval = setInterval(() => {
gardenRef.current?.render();
}, Garden.options.growSpeed);
animationRefs.current.intervals.push(renderInterval);
// 启动代码打字效果
typeWriterCodeContent();
// 光标闪烁效果
const cursorInterval = setInterval(() => {
if (clearValRef.current) {
setShowCursor(prev => !prev);
} else {
clearInterval(cursorInterval);
setShowCursor(false);
}
}, 600);
animationRefs.current.intervals.push(cursorInterval);
// 5秒后开始心形动画
const heartTimeout = setTimeout(() => {
startHeartAnimation();
}, 5000);
animationRefs.current.timeouts.push(heartTimeout);
// 初始化计时器
timeElapse(startDate);
const timeInterval = setInterval(() => timeElapse(startDate), 500);
animationRefs.current.intervals.push(timeInterval);
// 窗口大小变化监听
const handleResize = () => adjustLayout();
window.addEventListener('resize', handleResize);
// 清理函数
return () => {
animationRefs.current.intervals.forEach(interval => clearInterval(interval));
animationRefs.current.timeouts.forEach(timeout => clearTimeout(timeout));
window.removeEventListener('resize', handleResize);
};
}, [startDate]);
// 显示消息后的副作用
useEffect(() => {
if (showMessages) {
adjustWordsPosition();
const timer = setTimeout(() => setShowLoveU(true), 5000);
animationRefs.current.timeouts.push(timer);
return () => clearTimeout(timer);
}
}, [showMessages]);
// 显示告白后的副作用
useEffect(() => {
if (showLoveU && loveURef.current) {
const loveUContent = `${messages.love}<br/><div class='signature'>${messages.signature}</div>`;
loveURef.current.innerHTML = '';
typeWriter(loveURef.current, loveUContent, 75);
}
}, [showLoveU, messages]);
// 同步clearVal状态到ref
useEffect(() => {
clearValRef.current = clearVal;
}, [clearVal]);
// ========== 工具函数 ==========
/**
* 代码打字效果
*/
const typeWriterCodeContent = () => {
setShowCursor(true);
let i = 0;
const speed = 10; // 打字速度(毫秒/字符)
const typing = setInterval(() => {
if (i < fullCodeContent.length) {
setCodeContent(fullCodeContent.substring(0, i + 1));
i++;
} else {
clearInterval(typing);
setClearVal(false); // 打字完成,停止光标闪烁
}
}, speed);
animationRefs.current.intervals.push(typing);
};
/**
* 计算心形曲线上的点
* @param angle 角度(弧度)
* @returns [x, y]坐标
*/
const getHeartPoint = (angle: number): [number, number] => {
// 心形曲线参数方程
const x = 19.5 * (16 * Math.pow(Math.sin(angle), 3));
const y = -20 * (13 * Math.cos(angle) - 5 * Math.cos(2 * angle) - 2 * Math.cos(3 * angle) - Math.cos(4 * angle));
// 计算相对于心形容器中心的坐标
const offsetX = loveHeartRef.current?.offsetWidth ? loveHeartRef.current.offsetWidth / 2 : 0;
const offsetY = loveHeartRef.current?.offsetHeight ? loveHeartRef.current.offsetHeight / 2 - 55 : 0;
return [offsetX + x, offsetY + y];
};
/**
* 开始心形动画
*/
const startHeartAnimation = () => {
const interval = 50; // 花朵生成间隔(毫秒)
const speed = 0.2; // 角度变化速度
let angle = 10; // 起始角度
const points: [number, number][] = []; // 已生成的点
const animation = setInterval(() => {
const point = getHeartPoint(angle);
let valid = true;
// 检查新点与已有点的距离
for (const p of points) {
const distance = Math.sqrt(Math.pow(p[0] - point[0], 2) + Math.pow(p[1] - point[1], 2));
if (distance < Garden.options.bloomRadius.max * 1.3) {
valid = false;
break;
}
}
// 如果点有效,创建花朵
if (valid && gardenRef.current) {
points.push(point);
gardenRef.current.createRandomBloom(point[0], point[1]);
}
// 动画结束条件
if (angle >= 30) {
clearInterval(animation);
setShowMessages(true); // 显示消息
} else {
angle += speed; // 继续动画
}
}, interval);
animationRefs.current.intervals.push(animation);
};
/**
* 通用打字机效果
* @param element 目标DOM元素
* @param text 要显示的文本
* @param speed 打字速度(毫秒/字符)
*/
const typeWriter = (element: HTMLElement, text: string, speed: number) => {
let i = 0;
element.innerHTML = '';
const typing = setInterval(() => {
if (i < text.length) {
const char = text.substr(i, 1);
// 跳过HTML标签
if (char === '<') {
const closingIndex = text.indexOf('>', i);
i = closingIndex === -1 ? text.length : closingIndex + 1;
} else {
i++;
}
// 更新内容并添加光标
element.innerHTML = text.substring(0, i) + (i % 2 ? '_' : '');
} else {
clearInterval(typing);
}
}, speed);
animationRefs.current.intervals.push(typing);
};
/**
* 计算并显示恋爱时长
* @param date 开始日期
*/
const timeElapse = (date: Date) => {
if (!elapseClockRef.current) return;
const now = new Date();
const seconds = (now.getTime() - date.getTime()) / 1000;
// 计算天数
const days = Math.floor(seconds / (3600 * 24));
let remaining = seconds % (3600 * 24);
// 计算小时
const hours = Math.floor(remaining / 3600);
remaining %= 3600;
// 计算分钟
const minutes = Math.floor(remaining / 60);
remaining %= 60;
// 格式化显示(补零)
const formattedHours = hours < 10 ? `0${hours}` : hours.toString();
const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes.toString();
const formattedSeconds = remaining < 10 ? `0${Math.floor(remaining)}` : Math.floor(remaining).toString();
// 更新DOM
elapseClockRef.current.innerHTML = `
<span class="digit">${days}</span> 天
<span class="digit">${formattedHours}</span> 小时
<span class="digit">${formattedMinutes}</span> 分钟
<span class="digit">${formattedSeconds}</span> 秒
`;
};
/**
* 调整文字位置
*/
const adjustWordsPosition = () => {
if (!wordsRef.current || !canvasRef.current) return;
const garden = canvasRef.current;
const words = wordsRef.current;
words.style.position = 'absolute';
words.style.top = `${garden.offsetTop + 195}px`;
words.style.left = `${garden.offsetLeft + 70}px`;
};
/**
* 调整代码区域位置
*/
const adjustCodePosition = () => {
if (!codeRef.current || !canvasRef.current) return;
const garden = canvasRef.current;
const code = codeRef.current;
code.style.marginTop = `${(garden.offsetHeight - code.offsetHeight) / 2}px`;
};
/**
* 响应式布局调整
*/
const adjustLayout = () => {
if (!contentRef.current || !loveHeartRef.current || !codeRef.current) return;
const content = contentRef.current;
const loveHeart = loveHeartRef.current;
const code = codeRef.current;
// 计算合适尺寸
const width = loveHeart.offsetWidth + code.offsetWidth;
const height = Math.max(loveHeart.offsetHeight, code.offsetHeight);
// 设置容器尺寸(考虑窗口边界)
content.style.width = `${Math.min(width, window.innerWidth - 40)}px`;
content.style.height = `${Math.min(height, window.innerHeight - 40)}px`;
// 居中显示
content.style.marginTop = `${Math.max((window.innerHeight - content.offsetHeight) / 2, 10)}px`;
content.style.marginLeft = `${Math.max((window.innerWidth - content.offsetWidth) / 2, 10)}px`;
// 调整代码区域垂直居中
adjustCodePosition();
};
/**
* 渲染代码区域
*/
const renderCodeContent = () => {
return (
<div id="code" ref={codeRef}>
{/* 使用dangerouslySetInnerHTML显示带HTML格式的代码 */}
<div dangerouslySetInnerHTML={{ __html: codeContent }} />
{/* 闪烁的光标(心形) */}
{showCursor && (
<span className="heart-cursor" style={{ color: 'red' }}>♥</span>
)}
</div>
);
};
// ========== 组件渲染 ==========
return (
<div className="btnbg lovePage">
{/* 背景层 */}
<div id="mainDiv">
{/* 主内容容器 */}
<div id="content" ref={contentRef}>
{/* 左侧:代码区域 */}
{renderCodeContent()}
{/* 右侧:心形动画区域 */}
<div id="loveHeart" ref={loveHeartRef}>
{/* 花园画布 */}
<canvas id="garden" ref={canvasRef}></canvas>
{/* 情话文本区域(默认隐藏) */}
<div
id="words"
ref={wordsRef}
style={{
display: showMessages ? 'block' : 'none',
opacity: showMessages ? 1 : 0,
transition: 'opacity 1s ease-in-out'
}}
>
{/* 初始消息 */}
<div id="messages" ref={messagesRef}>
{messages.initial}
{/* 恋爱计时器 */}
<div id="elapseClock" ref={elapseClockRef}></div>
</div>
{/* 最终告白(默认隐藏) */}
<div
id="loveu"
ref={loveURef}
style={{
display: showLoveU ? 'block' : 'none',
opacity: showLoveU ? 1 : 0,
transition: 'opacity 1s ease-in-out'
}}
/>
</div>
</div>
</div>
</div>
{/* 浏览器兼容性错误提示 */}
<div id="errorMsg" ref={errorMsgRef}></div>
</div>
);
};
export default LoveAnimation;
GardenClasses.ts文件:
javascript
// GardenClasses.ts
export interface VectorProps {
x: number;
y: number;
}
export interface PetalOptions {
stretchA: number;
stretchB: number;
startAngle: number;
angle: number;
growFactor: number;
bloom: Bloom;
}
export interface BloomOptions {
p: Vector;
r: number;
c: string;
pc: number;
garden: Garden;
}
export interface GardenOptions {
petalCount: { min: number; max: number };
petalStretch: { min: number; max: number };
growFactor: { min: number; max: number };
bloomRadius: { min: number; max: number };
density: number;
growSpeed: number;
color: {
rmin: number;
rmax: number;
gmin: number;
gmax: number;
bmin: number;
bmax: number;
opacity: number;
};
tanAngle: number;
}
export class Vector {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
rotate(angle: number): Vector {
const x = this.x;
const y = this.y;
this.x = Math.cos(angle) * x - Math.sin(angle) * y;
this.y = Math.sin(angle) * x + Math.cos(angle) * y;
return this;
}
mult(factor: number): Vector {
this.x *= factor;
this.y *= factor;
return this;
}
clone(): Vector {
return new Vector(this.x, this.y);
}
length(): number {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
subtract(v: Vector): Vector {
this.x -= v.x;
this.y -= v.y;
return this;
}
set(x: number, y: number): Vector {
this.x = x;
this.y = y;
return this;
}
}
export class Petal {
stretchA: number;
stretchB: number;
startAngle: number;
angle: number;
bloom: Bloom;
growFactor: number;
r: number;
isfinished: boolean;
constructor(options: PetalOptions) {
this.stretchA = options.stretchA;
this.stretchB = options.stretchB;
this.startAngle = options.startAngle;
this.angle = options.angle;
this.bloom = options.bloom;
this.growFactor = options.growFactor;
this.r = 1;
this.isfinished = false;
}
draw(): void {
const ctx = this.bloom.garden.ctx;
const e = new Vector(0, this.r).rotate(Garden.degrad(this.startAngle));
const d = e.clone().rotate(Garden.degrad(this.angle));
const c = e.clone().mult(this.stretchA);
const b = d.clone().mult(this.stretchB);
ctx.strokeStyle = this.bloom.c;
ctx.beginPath();
ctx.moveTo(e.x, e.y);
ctx.bezierCurveTo(c.x, c.y, b.x, b.y, d.x, d.y);
ctx.stroke();
}
render(): void {
if (this.r <= this.bloom.r) {
this.r += this.growFactor;
this.draw();
} else {
this.isfinished = true;
}
}
}
export class Bloom {
p: Vector;
r: number;
c: string;
pc: number;
petals: Petal[];
garden: Garden;
constructor(options: BloomOptions) {
this.p = options.p;
this.r = options.r;
this.c = options.c;
this.pc = options.pc;
this.petals = [];
this.garden = options.garden;
this.init();
this.garden.addBloom(this);
}
draw(): void {
let isFinished = true;
this.garden.ctx.save();
this.garden.ctx.translate(this.p.x, this.p.y);
for (const petal of this.petals) {
petal.render();
isFinished = isFinished && petal.isfinished;
}
this.garden.ctx.restore();
if (isFinished) {
this.garden.removeBloom(this);
}
}
init(): void {
const angle = 360 / this.pc;
const startAngle = Garden.randomInt(0, 90);
for (let i = 0; i < this.pc; i++) {
this.petals.push(
new Petal({
stretchA: Garden.random(Garden.options.petalStretch.min, Garden.options.petalStretch.max),
stretchB: Garden.random(Garden.options.petalStretch.min, Garden.options.petalStretch.max),
startAngle: startAngle + i * angle,
angle: angle,
growFactor: Garden.random(Garden.options.growFactor.min, Garden.options.growFactor.max),
bloom: this,
})
);
}
}
}
export class Garden {
blooms: Bloom[];
element: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
static options: GardenOptions = {
petalCount: { min: 8, max: 15 },
petalStretch: { min: 0.1, max: 3 },
growFactor: { min: 0.1, max: 1 },
bloomRadius: { min: 8, max: 10 },
density: 10,
growSpeed: 1000 / 60,
color: {
rmin: 128,
rmax: 255,
gmin: 0,
gmax: 128,
bmin: 0,
bmax: 128,
opacity: 0.1,
},
tanAngle: 60,
};
constructor(ctx: CanvasRenderingContext2D, element: HTMLCanvasElement) {
this.blooms = [];
this.element = element;
this.ctx = ctx;
}
render(): void {
for (const bloom of this.blooms) {
bloom.draw();
}
}
addBloom(bloom: Bloom): void {
this.blooms.push(bloom);
}
removeBloom(bloom: Bloom): void {
const index = this.blooms.indexOf(bloom);
if (index !== -1) {
this.blooms.splice(index, 1);
}
}
createRandomBloom(x: number, y: number): void {
this.createBloom(
x,
y,
Garden.randomInt(Garden.options.bloomRadius.min, Garden.options.bloomRadius.max),
Garden.randomrgba(
Garden.options.color.rmin,
Garden.options.color.rmax,
Garden.options.color.gmin,
Garden.options.color.gmax,
Garden.options.color.bmin,
Garden.options.color.bmax,
Garden.options.color.opacity
),
Garden.randomInt(Garden.options.petalCount.min, Garden.options.petalCount.max)
);
}
createBloom(x: number, y: number, radius: number, color: string, petalCount: number): void {
new Bloom({
p: new Vector(x, y),
r: radius,
c: color,
pc: petalCount,
garden: this,
});
}
clear(): void {
this.blooms = [];
this.ctx.clearRect(0, 0, this.element.width, this.element.height);
}
static random(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
static randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
static readonly circle = 2 * Math.PI;
static degrad(angle: number): number {
return (Garden.circle / 360) * angle;
}
static raddeg(angle: number): number {
return (angle / Garden.circle) * 360;
}
static rgba(r: number, g: number, b: number, a: number): string {
return `rgba(${r},${g},${b},${a})`;
}
static randomrgba(
rmin: number,
rmax: number,
gmin: number,
gmax: number,
bmin: number,
bmax: number,
a: number
): string {
const r = Math.round(Garden.random(rmin, rmax));
const g = Math.round(Garden.random(gmin, gmax));
const b = Math.round(Garden.random(bmin, bmax));
const threshold = 5;
if (
Math.abs(r - g) <= threshold &&
Math.abs(g - b) <= threshold &&
Math.abs(b - r) <= threshold
) {
return Garden.rgba(rmin, rmax, gmin, gmax, bmin, bmax, a);
} else {
return Garden.rgba(r, g, b, a);
}
}
}
Love.less
javascript
// 主色调(基于 #ffc0cb 扩展的渐变色系)
@color-1: #ffc0cb; // 粉红
@color-2: #ffb6c1; // 稍暗的粉
@color-3: #ffd1dc; // 浅粉
@color-4: #ffdfed; // 更浅的粉
@color-5: #ffecf2; // 接近白色
@font-face {
font-family: digit;
src: url('digital-7_mono.ttf') format("truetype");
}
// 动画定义
.keyframes() {
@keyframes gentleFlow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
}
// 主背景样式
.lovePage {
min-height: 100vh;
background: linear-gradient(45deg,
@color-1,
@color-2,
@color-3,
@color-4,
@color-5,
@color-1 );
background-size: 300% 300%;
animation: gentleFlow 12s ease infinite;
position: relative;
overflow: hidden;
.keyframes();
// 光斑效果(增强层次感)
&::before {
content: '';
position: absolute;
width: 200%;
height: 200%;
background:
radial-gradient(circle at 70% 20%, rgba(255, 255, 255, 0.2) 0%, transparent 30%),
radial-gradient(circle at 30% 80%, rgba(255, 255, 255, 0.15) 0%, transparent 30%);
animation: gentleFlow 20s linear infinite reverse;
}
}
canvas {
padding: 0;
margin: 0;
}
div.btnbg {
width: 100%;
height: 100%;
}
#code,#messages,#loveu{
color: #333;
}
#mainDiv {
width: 100%;
height: 100%
}
#loveHeart {
width: 670px;
height: 625px
}
#garden {
width: 100%;
height: 100%
}
#elapseClock {
text-align: right;
font-size: 18px;
margin-top: 10px;
margin-bottom: 10px
}
#words {
font-family: "sans-serif";
width: 500px;
font-size: 24px;
color: #666
}
#elapseClock .digit {
font-family: "digit";
font-size: 36px
}
#loveu {
padding: 5px;
font-size: 22px;
margin-top: 40px;
margin-right: 120px;
text-align: right;
display: none
}
#loveu .signature {
margin-top: 10px;
font-size: 20px;
font-style: italic
}
#clickSound {
display: none
}
#content{
display: flex;
justify-content: center;
align-items: center;
}
#code {
width: 440px;
height: 400px;
color: #333;
font-family: "Consolas","Monaco","Bitstream Vera Sans Mono","Courier New","sans-serif";
font-size: 12px;
margin: 0 !important;
}
.string {
color: #2a36ff
}
.keyword {
color: #7f0055;
font-weight: bold
}
.placeholder {
margin-left: 15px
}
.space {
margin-left: 7px
}
.comments {
color: #3f7f5f
}
#copyright {
margin-top: 10px;
text-align: center;
width: 100%;
color: #666
}
#errorMsg {
width: 100%;
text-align: center;
font-size: 24px;
position: absolute;
top: 100px;
left: 0
}
#copyright a {
color: #666
}
.heart-cursor {
animation: blink 1s infinite;
font-size: 1em;
vertical-align: middle;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
