扔番茄特效 —— 简易复刻2025清华镜像愚人节小彩蛋。(vue)

1,072 阅读9分钟

前言

愚人节的时候,看到了清华镜像愚人节的小彩蛋,觉得挺有意思的,然后简单复刻了一下。没有100%还原,轻喷交流。 创作不易, 收藏点赞。

代码仓库以及效果演示

码上掘金

建议收起代码块使用

代码仓库

throw_tomatoes: vue3 + vite6.0,复刻2025清华源愚人节扔番茄砸中元素的彩蛋。

动图演示

te9m8-secdi.gif

愚人节彩蛋

清华大学开源软件镜像站 | Tsinghua Open Source Mirror 在愚人节当天更新了一个小彩蛋: 当点击页面时,会扔出一个完整的番茄,类似坠落位移一段距离后砸在页面上变成烂番茄,在砸落点触发点击事件, 而原本位置的点击事件失效

d6e59234bb194ec41e0117b2a2eb5fd.png

image.png

思路分析

需要复刻这个彩蛋,涉及到毫无疑问是点击事件动画以及元素定位

F12打开控制台扔几个番茄可以看到:

ac24f7671475289ee54f1efa5220138.png

v2-ceffeea02ea38faed7b4df79559ea456_b.jpg

思考一下,应该是点击时确定起始位置, 再通过一定的公式持续计算更新番茄的运动路径大小更新transform模拟出坠落的感觉。

核心代码以及思路

image.png

throwTomatoes.vue 扔番茄页面

<template>
    <div class="layout-ctn" @click="handleClick">
        <div style="display: flex; flex-wrap: wrap;">
            <TargetEl style="width: calc(10% - 2px) ;" v-for="(i) in 1000"></TargetEl>
        </div>
        
        <Tomato v-for="(node, nodeIndex) in list" :position="node.position" :key="node.id"></Tomato>
    </div>
</template>

<script setup>
import { ref } from 'vue';
import Tomato from '../../components/tomato.vue';
import TargetEl from '../../components/targetEl.vue';
const list = ref([]);
const handleClick = (event) => {
    console.log(event)
    // 如果不是由扔番茄引起的点击事件,则是人为的人点事件,新增一个番茄
    if (event.detail !== 1002) {
        list.value.push({
            id: event.timeStamp,
            position: {
                clientX: event.clientX, 
                clientY: event.clientY,
                pageX: event.pageX, 
                pageY: event.pageY
               // x: event.pageX, y: event.pageY
            }
        });
    }
}
</script>

这块主要做的处理:

  1. 布置靶子元素,用于显示扔出的番茄有没有命中触发点击事件。

  2. handleClick 监听点击事件,记录点击时候的位置,抛掷番茄。 此处记录了两种位置, 一种是pageXpageY, 一种是 clientXclientY。 这两者的区别可以看一下 1. clientX 和 clientY

    • 定义clientX 和 clientY 表示鼠标指针相对于浏览器视口(viewport)的水平和垂直坐标。视口是指浏览器中实际显示网页内容的区域,不包括浏览器的滚动条、地址栏等界面元素。

    2. pageX 和 pageY

    • 定义pageX 和 pageY 表示鼠标指针相对于整个文档(document)的水平和垂直坐标,包括由于滚动而隐藏在视口之外的部分。也就是说,pageX 和 pageY 会考虑页面的滚动位置。

    简单总结 clientX 、 clientYpageX 、 pageY最大的差别其实就是加不加上横向滚动条纵向滚动条的距离。

tomato.vue 番茄组件

<template>
    <div class="tomato-ctn"
        :style="{ left: `${props.position.pageX}px`, top: `${props.position.pageY}px`, transform: transformText }">
        <GoodTomato v-if="!isBroken" class="good-tomato">
        </GoodTomato>
        <BadTomato class="bad-tomato" v-else :style="{ transform: `scale(${scale * 3})` }"></BadTomato>
        <div v-if="isMiss" class="unhit-tips" :style="{ transform: `scale(${1 / scale})` }">
            未命中!!
        </div>
    </div>
</template>

<script setup>
import { computed, onMounted, ref, toRaw } from 'vue';
import GoodTomato from '../assets/tomato_good.svg';     // 完好的番茄
import BadTomato from '../assets/tomato.svg';           // 坏掉的番茄
const props = defineProps({
    position: {
        default: () => {
            return {
                clientX: -100,
                clientY: -100,
                pageX: -100,
                pageY: -100
            }
        }
    }
})

const x = ref(props.position.pageX);    // 元素当前的pageX

const y = ref(props.position.pageY);    // 元素当前的pageY

const eX = ref(0);             // 元素最终的位置pageX

const eY = ref(0);              // 元素最终的位置pageY

const pageX = ref(0);   // 点击时候的pageX

const pageY = ref(0);   // 点击时候的pageY

const scale = ref(1);               // 缩放倍率

const isBroken = ref(false);        // 控制番茄样式
/**
 * 模拟从点击点到目标点的抛物线轨迹(可调节弯度)
 * @param {number} y1 - 当前纵向坐标
 * @param {number} startY - 起始点的纵坐标 (y)
 * @param {number} targetX - 目标点的横坐标 (x ± 20)
 * @param {number} startX - 起始点的横坐标 (x)
 * @param {number} [curvature=4] - 控制抛物线弯度的参数(值越大,弯度越明显)
 * @returns {number} x1 - 对应纵坐标 y1 的横坐标
 */
function parabolicTrajectory(y1, startY, targetX, startX, curvature = 4) {
    // 计算纵向总距离
    const totalYDistance = 100; // y + 200 - y = 200
    // 计算当前纵向位置占总距离的比例 (0 到 1)
    const t = (y1 - startY) / totalYDistance;

    // 如果 y1 不在起始点和目标点之间,返回边界值
    if (t <= 0) return startX;
    if (t >= 1) return targetX;

    // 计算横向总距离(可能是正或负,取决于目标点在左还是右)
    const totalXDistance = targetX - startX;

    // 使用抛物线方程 x = startX + totalXDistance * t + (调整弯度的二次项)
    // 二次项系数 curvature 控制弯度(默认 4,值越大弯度越明显)
    const x1 = startX + totalXDistance * t + (-curvature * totalXDistance * t * (1 - t));

    return x1;
}

// 位置偏移量
const transformText = computed(() => {
    // 偏移量为: 当前坐标 - 起始坐标 - 32(元素的长宽为64,以中心为基点,所以减去32)
    return `translate(${pageX.value -props.position.pageX - 32}px, ${pageY.value -props.position.pageY - 32}px) scale(${scale.value})`;
})

const timer = ref();    // 动画的timer

const targetEl = ref();     // 最终集中的目标元素

const isMiss = ref(false);      // 是否命中

// 命中
const attack = () => {
    // 当元素存在的时候
    if (targetEl.value) {
        // 创建一个原生的鼠标点击事件
        const clickEvent = new MouseEvent('click', {
            bubbles: true,       // 事件是否冒泡
            cancelable: true,    // 事件是否可以取消
            view: window,        // 关联的窗口对象
            clientX: x.value,          // 指定的 X 坐标(相对于视口)
            clientY: y.value,           // 指定的 Y 坐标(相对于视口)
            detail: 1002        // 用于区分时间是 代码触发  还是  人为触发
        });

        // 触发原生点击事件
        targetEl.value.dispatchEvent(clickEvent);
    } else {
        console.log('没有找到对应坐标的元素');
        isMiss.value = true;        // 没有命中
    }
}

// 开始动画
const startAnimate = () => {
    const oneStep = (eY.value - y.value) / 200; // 分 200步 执行
    const oneScaleStep = (0.6) / 200;   // 分 200步 执行
    timer.value = setInterval(() => {
        // 当没有到达终点位置的时候持续更新
        if (pageY.value + oneStep < eY.value) {
            pageY.value = pageY.value + oneStep;
            scale.value = scale.value - oneScaleStep;
            pageX.value = parabolicTrajectory(pageY.value, props.position.pageY, eX.value, props.position.pageX, 15);
        }
        else {
            timer.value && clearInterval(timer.value);
            timer.value = null;
            isBroken.value = true;  // 更新为烂番茄
            attack();   // 触发命中事件
        }
    }, 10);
}

onMounted(() => {
    // 初始化pageX和pageY
    pageX.value = props.position.pageX;   
    pageY.value = props.position.pageY;
    //let randomX = parseInt(Math.random() * 200 - 100);
    // x的随机偏移量, ±30 偏移一下
    let randomXMius = parseInt(Math.random() * 60 - 30);
    // 终止位置的pageX和pageY的值
    eX.value = pageX.value + randomXMius;
    eY.value = pageY.value + 100;
    // 计算当前终止的clientX和ClientY, 用于获取点击时最终击中的目标元素
    const currentEndClientX = props.position.clientX + randomXMius;
    const currentEndClientY = props.position.clientY + 100;
    targetEl.value = document.elementFromPoint(currentEndClientX, currentEndClientY);
    // 开始动画
    startAnimate();
})

</script>

<style lang="less" scoped>
.good-tomato {
    width: 64px;
    height: 64px;
    animation: rotateScale .3s infinite ease;
}

.bad-tomato {
    width: 64px;
    height: 64px;
}

.tomato-ctn {
    width: 64px;
    height: 64px;

    /* 应用动画 */
    position: absolute;
}

.unhit-tips {
    color: red;
    position: absolute;
    top: -24px;
    text-align: center;
    width: 100px;
    user-select: none;
    animation: tipAnimation .8s ease-in-out forwards;
}

@keyframes tipAnimation {
    0% {
        top: -24px;
        opacity: 1;
    }
    40% {
        top: -38px;
        opacity: 0.5;
    }
    80% {
        top: -50px;
        opacity: 0.1;
    }
    100% {
        display: none;
    }
}

/* 定义动画 */
@keyframes rotateScale {
    0% {
        transform: rotate(0deg);
        /* 初始状态:无旋转,正常大小 */
    }

    50% {
        transform: rotate(180deg);
        /* 中间状态:旋转180度,放大1.5倍 */
    }

    100% {
        transform: rotate(360deg);
        /* 结束状态:旋转360度,恢复原大小 */
    }
}
</style>

主要思路是:在初始化的时候, 以传入的点击事件的位置(pageX, pageY)为起始值, 并提前决定好落点位置 eXeY, 再把落点位置的Y值和起始位置的差值分200份, 即 const oneStep = (eY.value - y.value) / 200; // 分 200步 执行, 再使用setInterval每10毫秒执行一次更新番茄位置的Y值, 即 pageY.value = pageY.value + oneStep; 再通过曲线函数方法parabolicTrajectory函数,输入pageY获取到对应曲线路径pageX, 重复执行直至到达终点位置更新isBroken变量为true,将 完整番茄的SVG图像 置换为 烂番茄(破碎番茄)的SVG图像, 最后手动触发一个MouseEvent表示砸中后点点击事件, 即

// 创建一个原生的鼠标点击事件
        const clickEvent = new MouseEvent('click', {
            bubbles: true,       // 事件是否冒泡
            cancelable: true,    // 事件是否可以取消
            view: window,        // 关联的窗口对象
            clientX: x.value,          // 指定的 X 坐标(相对于视口)
            clientY: y.value,           // 指定的 Y 坐标(相对于视口)
            detail: 1002        // 用于区分时间是 代码触发  还是  人为触发
        });

        // 触发原生点击事件
        targetEl.value.dispatchEvent(clickEvent);

这个组件主要做了以下的注意点:

  1. 因为使用了document.elementFromPoint(x,y)获取最终下落点击中的元素, 坐标 (x, y) 是基于视口的,而不是整个页面。 在组件初始化的时候就通过点击事件(clientX, clientY) 加上偏移量提前获取到番茄击中的元素targetEl, 并在击中后触发点击事件
  2. 番茄组件 应该使用absolte和(pageX, pageY) 来确定番茄的位置, 而不是使用fixed和(clientX, clientY)来确定,两者的区别是一个是基于整个页面的定位,一个是基于视口(viewpoint) 的定位。 当页面存在滚动条的时候, 就能看到问题了。
  3. 在创建番茄砸中的点击事件 MouseEvent 的时候, 传入了参数 detail: 1002, 用表示是番茄砸中触发的点击事件,与用户点击的事件区分开来。 为什么需要区分呢? 番茄的生成是由用户发起点击事件生成的, 如果不区分, 番茄砸中的点击事件也会生成一个番茄,砸中后再触发生成一个,无限循环,可以注释一下试试看。
  4. parabolicTrajectory 函数确定了番茄 弧形运动的曲线, 可以通过参数来调整弧度, 这个我是让deepSeek帮我写的。
  5. 使用了一个动画 rotateScale 作为 完整番茄的SVG图像 的旋转动画。

targetEl.vue 靶子元素

主要用于响应番茄砸中触发的点击事件

<!--
 * @Author: Damon Liu
 * @Date: 2025-04-07 10:59:20
 * @LastEditors: Damon Liu
 * @LastEditTime: 2025-04-09 13:46:46
 * @Description:  用于被标记是否砸中的元素
-->
<template>
    <div :class="['target-el-ctn', isAttacked ? 'attcked-target-ctn' : ''] " @click="handleClick">
        {{  !isAttacked ? '未命中' : '命中!'  }}
    </div>
</template>

<script setup>
import { ref } from 'vue'
const isAttacked = ref(false);  // 是否被命中
const handleClick = (event) => {
    console.log(event)
    if(event.detail === 1002) {
        // 当事件为扔番茄击中时停止冒泡
        // 但其实不停止也无所谓,在最顶层的时候做判断也一个样
        event.stopPropagation();
        isAttacked.value = true
    }
}
</script>

<style lang="less" scoped>
.target-el-ctn {
    border: 1px solid #eee;
    color: #fff;
    background: #7bcda8;
    user-select: none;
}
.attcked-target-ctn {
    background: #42b883;
    color: #fff;
}
</style>

只响应 detail: 1002 的点击事件, 即番茄砸中触发的点击事件。

总结和优化

挺好玩的一个小彩蛋,包含动画(transform,setInterval)点击事件(MouseEvent:click)视口距离(clientX, clientY)页面距离(pageX, pageY), 曲线运动等内容和知识点。 实际做下来也挺好玩, 就是在曲线上没有很好的模拟到那种坠落感,也想过可以劫持元素的点击事件,而不需要做成这种形式, 动画形式也许也可以尝试使用requestAnimationFrame的形式复写一下。还是有很多可以进一步优化的空间。

创作不易,点赞收藏!

参考来源

完整番茄的SVG来源: iconfont-阿里巴巴矢量图标库

破碎番茄的SVG来源: throw_tomatoes: vue3 + vite6.0,复刻2025清华源愚人节扔番茄砸中元素的彩蛋。