之前在某個前端技術部落格看過一種很酷的特效:瀏覽網頁時有一堆綠色小點追著滑鼠游標跑,滑鼠移到哪裡,整群小點就跟到哪裡。早不記得是在哪裡看到的,自然也很難再找到連結給大家參考,但接近以下這種展示效果

粒子效果模擬

說真的,在網頁要追蹤滑鼠游標並不難,從 onmousehover 事件取得目前游標座標,想追著游標跑,可依據游標 X,Y 與追蹤者的 X,Y 算出游標位於追蹤者的哪個方位,有了角度再用三角函數計算每一步的 X, Y 軸移動量,持續增減 X 及 Y,追蹤者便會朝著游標前進。

題外話:爬文過程發現可愛的游標貓 Neko,程式碼便有抓游標角度的範例(用 Atan2),至於移動它是用角度區間分成米字的八個方向(上,右上,右,右下...),配合播放朝八個方向跑的貓咪動畫:

// get mouse direction
r := math.Atan2(float64(y), float64(x))
if r <= 0 {
	tr = 360
}

一直覺得這題目挺有趣,但裡面有不少數學,想在網頁操控物件旋轉、前進要用到 CSS/JS 技巧,以前的我可能會嫌花時間燒腦而猶豫;但現在有 Github Copliot 黑魔法,寫起來應該不會太費力,那還等什麼?就來寫個 Vue.js 物件化導向版本練手感。
(事後證明確實超省力,程式用到的數學算式、Vue.js 語法、CSS 樣式幾乎都靠 Github Copilot 輸出,我負責監工及修改調整,從無到有不到兩小時)

我的構想是在網頁上放幾十個 <div>,當游標在網頁上移動時,這幾十個 DIV 會朝著游標前進。由於想精準觀察 DIV 有正確轉向,我決定用 CSS border 技巧將 DIV 畫成三角形,並透過 CSS transform: 'rotate(90deg)', 樣式旋轉。為方便理解及修改,我打算定義用自訂元素 Arrow,輸入 x, y, rotate(旋轉角度), color 以便在指定位置繪製箭頭 DIV。為進一步物件導向化,我再定義了 ArrowData 類別,除保存 x, y, rotate, color 等屬性外,新增 aim(x, y) 重算 rotate 以瞄準 x, y 、calcDistance(x, y) 計算與 x, y 間的距離、move(step) 朝著 rotate 所指角度進 step 距離... 等三個方法。

接下來,透過 Vue 依據 ArrowData 陣列在頁面上產生 N 個 Arrow 自訂元件,當游標移動時,跑迴圈執行 ArrowData aim() 更新朝向角度,再用 setInterval() 跑迴圈呼叫每個箭頭的 move() 向游標前進,基本雛型就完成了。

噗,簡單試玩,我竟有種被喪屍包圍的緊張感,練功程式寫著寫著,竟寫出了遊戲娛樂性。

我決定加碼,加入爆擊功能,點滑鼠左鍵可將方圓 100px 內的箭頭震開,如以下效果:

看起挺有趣的,但原理不難。在 onclick 事件跑迴圈呼叫所有箭頭的 calcDistance() 算距離,找出方圓 100px 以內的箭頭,依遠近決算威力大小再配上一點亂數決定彈開距離,呼叫 move() 負值使其向後移動,並用亂數重設 rotate 角度,最後配上橘紅圈圈由小變大的 CSS 動畫,連爆炸特效都有了,效果意外的好。而物件導向程式,修改起來就是這麼輕鬆愉快。

最終成品還包含決定數箭頭數量、活動範圍輸入欄位,按下 Restart 就可以開始玩:

全部程式連同 HTML、CSS 不超過 250 行,單一 HTML 搞定,符合我對簡單輕巧的要求。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Cursor Followers</title>
    <script src="https://unpkg.com/vue@3"></script>
    <style>
        html,body,input,select,button { font-size: 9pt; }
        .arrow {
            width: 0;
            height: 0;
            border-left: 5px solid transparent;
            border-right: 5px solid transparent;
            border-bottom-width: 10px;
            border-bottom-style: solid;
            position: absolute;
        }
        .canvas {
            position: relative;
            border: 1px solid black;
            margin: 10px 0;
            background-color: #eee;
        }
        .boom {
            width: 10px; /* Initial diameter (2 * radius) */
            height: 10px;
            background-color: orangered;
            border-radius: 50%;
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            animation: explode 0.8s forwards;
        }

        @keyframes explode {
            from {
                width: 10px; /* Initial diameter (2 * radius) */
                height: 10px;
            }
            to {
                width: 100px; /* Final diameter (2 * radius) */
                height: 100px;
                opacity: 0;
            }
        }

    </style>
</head>

<body>
    <div id="app">
        <div>
            Count: <input type="number" v-model="count" min="0" max="100"> /
            Size: <select v-model="size">
                <option value="360x270">360x270</option>
                <option value="480x360">480x360</option>
                <option value="640x480">640x480</option>
                <option value="800x600">800x600</option>
            </select> /
            <button @click="setup">Restart</button>
        </div>
        <div class="canvas" :style="{width: width + 'px', height: height + 'px'}" 
            @mouseenter="active = true" @mouseleave="active = false"
            @mousemove="handleMouseMove" @click="explode">
            <arrow v-for="arrow in arrows" :x="arrow.x" :y="arrow.y" :rotate="arrow.rotate" :color="arrow.color">
            </arrow>
            <div class="boom" v-if="active && boom" :style="{left: cursorX + 'px', top: cursorY + 'px'}"></div>
        </div>
    </div>
    <div class="circle"></div>
    <script>
        const Arrow = Vue.defineComponent({
            props: {
                x: {
                    type: Number,
                    required: true
                },
                y: {
                    type: Number,
                    required: true
                },
                rotate: {
                    type: Number,
                    required: true
                },
                color: {
                    type: String,
                    required: true
                }
            },
            template: `
                <div 
                    class="arrow" 
                    :style="{
                        position: 'absolute',
                        left: x + 'px',
                        top: y + 'px',
                        transform: 'rotate(' + rotate + 'deg)',
                        'border-bottom-color': color
                    }">
                </div>
            `
        });

        class ArrowData {
            static minX = 0;
            static minY = 0;
            static maxX = 640;
            static maxY = 480;
            constructor(x, y, rotate, color) {
                this.x = x;
                this.y = y;
                this.rotate = rotate;
                this.color = color;
            }
            aim(x, y) {
                const dy = y - this.y;
                const dx = x - this.x;
                let angle = Math.atan2(dy, dx) / Math.PI * 180 + 90;;
                if (angle < 0) {
                    angle += 360; // 確保角度在 0 到 360 度之間
                }
                this.rotate = angle;
            }
            calcDistance(x, y) {
                return Math.sqrt((x - this.x) ** 2 + (y - this.y) ** 2);
            }
            move(step) {
                // 朝著 rotate 的方向移動
                const radian = (this.rotate -90)/ 180 * Math.PI;
                this.x += Math.cos(radian) * step;
                this.y += Math.sin(radian) * step;
                if (this.x < ArrowData.minX) this.x = ArrowData.minX;
                if (this.x > ArrowData.maxX) this.x = ArrowData.maxX;
                if (this.y < ArrowData.minY) this.y = ArrowData.minY;
                if (this.y > ArrowData.maxY) this.y = ArrowData.maxY;
            }            
        }
        const randomColors = ['red', 'orange', 'green', 'blue', 'indigo', 'magenta', 'black'];

        const app = Vue.createApp({
            components: {
                Arrow
            },
            data() {
                return {
                    width: 640,
                    height: 480,
                    size: '640x480',
                    cursorX: 0,
                    cursorY: 0,
                    count: 25,
                    arrows: [],
                    lines: [],
                    active: false,
                    boom: false
                }
            },
            methods: {
                setup() {
                    this.arrows = [];
                    this.lines = [];
                    const [width, height] = this.size.split('x');
                    this.width = width;
                    this.height = height;
                    this.cursorX = width / 2;
                    this.cursorY = height / 2;
                    const padding = 10;
                    const genXY = () => [
                        Math.random() * (this.width - (padding * 2)) + padding,
                        Math.random() * (this.height - (padding * 2)) + padding
                    ];
                    ArrowData.minX = ArrowData.minY = padding;
                    ArrowData.maxX = this.width - padding;
                    ArrowData.maxY = this.height - padding;
                    for (let i = 0; i < this.count; i++) {

                        let [x, y] = genXY(); // 計算與已存在者距離,避免重疊
                        while (this.arrows.some(arrow => arrow.calcDistance(x, y) < 20)) {
                            [x, y] = genXY();
                        }
                        this.arrows.push(new ArrowData(
                            x, y,
                            0, //Math.random() * 360,
                            randomColors[Math.floor(Math.random() * randomColors.length)]
                        ));
                    }
                },
                handleMouseMove(event) {
                    if (!event.target.classList.contains('canvas')) return;
                    const rect = event.target.getBoundingClientRect();
                    this.cursorX = event.clientX - rect.left;
                    this.cursorY = event.clientY - rect.top;
                    for (const arrow of this.arrows) {
                        arrow.aim(this.cursorX, this.cursorY);
                    }
                },
                explode() {
                    for (const arrow of this.arrows) {
                        const distance = arrow.calcDistance(this.cursorX, this.cursorY);
                        if (distance < 100) {
                            // 距離越近,移動距離越遠
                            const step = -1000 / distance * (Math.random() * 2 + 0.5);
                            arrow.move(step);
                            arrow.rotate += Math.random() * 360;
                        }
                    }
                    const self = this;
                    self.boom = true;
                    setTimeout(() => {
                        self.boom = false;
                    }, 800);
                }
            }
        });
        const vm = app.mount('#app');
        setInterval(() => {
            if (!vm.active) return;
            for (const arrow of vm.arrows) {
                arrow.move(2);
            }
        }, 200);
    </script>
</body>

</html>

附上線上展示,有興趣的同學可以玩看看。

I created a Vue.js project where multiple triangular DIV elements follow the mouse cursor. The project demonstrates mouse tracking, geometric calculations, and CSS transformations. The triangles follow the cursor, and clicking on the page triggers an explosion effect that pushes them away. Check out the online demo for a live experience.


Comments

Be the first to post a comment

Post a comment