快轉到主要內容
  1. 全部文章/

Hugo 動畫背景|3D網格加代碼瀑布風格

·2580 字·6 分鐘
分類: 網站架設
標籤: Hugo
目錄
Hugo Blowfish 主題客製化 - 本文屬於一個選集。
§ 4: 本文

前言
#

原先我的主網站「YoZ Music 鼓手柚子 」和生活向部落格「YoZ Blog 」的背景是用 Blowfish 主題中的background佈局(Layout),訪問這兩個網站就可以看到,背景是我的舞台照上層覆蓋了半透明的單色濾鏡。

資訊類的文章獨立移到這裡後,畢竟文章主軸跟我「個人」沒什麼關係,便將背景改回純色,但看上去就是有點單調。想到之前看到的 React Bits - Animated UI Components For React 這個網站,裡面有很多酷炫的 React 背景元件但 Hugo 不能直接使用,我便透過 Perplexity 用 JavaScript 做出了「由中心向外波動」並且能跟「游標位置互動」的3D網格動畫,並且在畫面上段空白處加入了「代碼瀑布 」動畫

Demo 展示
#

新增 CSS 設定
#

編輯/assets/css路徑下的custom.css,添加:

/* 3D 網格加代碼瀑布風格動畫背景 */
/* /layouts/partials/3d-grid-n-digi-rain-bg.html */
#three-d-grid-n-digi-rain-bg {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: -1;
    perspective: 1000px;
}

/* 文章閱讀模式 - 動畫背景透明度由全局參數配置文件 params.toml 中 animationBackground.readingModeOpacity 控制 */
.animation-bg-reading-opacity.reading-mode #three-d-grid-n-digi-rain-bg {
  opacity: var(--animation-bg-reading-opacity, 0.35);
}

創建 JavaScript
#

/assets/js路徑下創建3d-grid-n-digi-rain-bg.js並編輯,如路徑不存在就手動建立。

const canvas = document.getElementById('3d-grid-n-digi-rain-bg');
const ctx = canvas.getContext('2d');

let width, height;
let mouseX = 0;
let mouseY = 0;
let time = 0;

// 代碼瀑布變數
const matrixChars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン가나다라마바사아자차카타파하거너더러머버서어저처커터퍼허고노도로모보소오조초코토포호구누두루무부수우주추쿠투푸후0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const fontSize = 16;
let columns;
let drops = [];
let speeds = [];
let trails = [];
let matrixTrails = [];

// 主題檢測
function isDarkMode() {
    return document.documentElement.classList.contains('dark') ||
           document.body.classList.contains('dark');
}

function resize() {
    width = canvas.width = window.innerWidth;
    height = canvas.height = window.innerHeight;
    columns = Math.floor(width / fontSize / 3);
    drops = [];
    speeds = [];
    trails = [];
    matrixTrails = [];
    
    for (let i = 0; i < columns; i++) {
        drops[i] = Math.random() * -50 - 10;
        speeds[i] = 0.15 + Math.random() * 0.15;
        trails[i] = 10 + Math.floor(Math.random() * 15);
        matrixTrails[i] = [];
        for (let t = 0; t < 30; t++) {
            matrixTrails[i][t] = matrixChars[Math.floor(Math.random() * matrixChars.length)];
        }
    }
}

resize();
window.addEventListener('resize', resize);

document.addEventListener('mousemove', (e) => {
    mouseX = e.clientX;
    mouseY = e.clientY;
});

// 監聽主題切換
const observer = new MutationObserver(() => {
    // 主題改變時重繪
});

observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ['class']
});

observer.observe(document.body, {
    attributes: true,
    attributeFilter: ['class']
});

// 繪製代碼瀑布
function drawMatrix() {
    const darkMode = isDarkMode();
    const bgOpacity = darkMode ? 0.05 : 0.08;
    const bgColor = darkMode ? '10, 10, 10' : '252, 252, 249';
    
    ctx.fillStyle = `rgba(${bgColor}, ${bgOpacity})`;
    ctx.fillRect(0, 0, width, height * 0.4);
    
    ctx.font = fontSize + 'px monospace';
    
    for (let i = 0; i < drops.length; i++) {
        const x = (i / columns) * width;
        const y = drops[i] * fontSize;
        const trailLength = trails[i];

        for (let t = 0; t < trailLength; t++) {
            const trailY = (drops[i] - t) * fontSize;
            
            if (trailY > 0 && trailY < height * 0.4) {
                // 定期更新字符(小概率)
                if (Math.random() > 0.95) {
                    matrixTrails[i][t % 30] = matrixChars[Math.floor(Math.random() * matrixChars.length)];
                }
                const char = matrixTrails[i][t % 30];
                
                let alpha, color;
                if (t === 0) {
                    // 瀑布頭部
                    alpha = 0.65;
                    color = darkMode ?
                        `rgba(95, 211, 160, ${alpha})` :
                        `rgba(45, 166, 178, ${alpha})`;
                } else {
                    // 瀑布拖尾 - 漸層變暗
                    alpha = Math.max(0, 1 - (t / trailLength));
                    if (darkMode) {
                        const green = Math.floor(200 - (t / trailLength) * 100);
                        color = `rgba(74, ${green}, 109, ${alpha * 0.5})`;
                    } else {
                        const brightness = Math.floor(128 - (t / trailLength) * 95);
                        color = `rgba(33, ${brightness}, 141, ${alpha * 0.4})`;
                    }
                }
                
                ctx.fillStyle = color;
                ctx.fillText(char, x, trailY);
            }
        }
        
        // 更新位置
        drops[i] += speeds[i];
        
        // 重置
        if (drops[i] * fontSize > height * 0.4 && Math.random() > 0.975) {
            drops[i] = -trails[i];
            speeds[i] = 0.15 + Math.random() * 0.15;
            trails[i] = 10 + Math.floor(Math.random() * 15);
            
            // 重置時更新整個字符數組
            matrixTrails[i] = [];
            for (let k = 0; k < 30; k++) {
                matrixTrails[i][k] = matrixChars[Math.floor(Math.random() * matrixChars.length)];
            }
        }
    }
}

// 繪製 3D 網格
function drawGrid() {
    const darkMode = isDarkMode();
    const gridSize = 40;
    const rows = 20;
    const cols = Math.ceil(width / gridSize) + 10;
    const centerX = width / 2;
    const centerY = height / 2;
    
    // 根據主題調整顏色
    const colors = darkMode ? [
        'rgba(74, 155, 109, 0.2)',
        'rgba(95, 211, 160, 0.2)',
        'rgba(60, 179, 113, 0.25)',
        'rgba(64, 224, 208, 0.2)',
        'rgba(72, 160, 200, 0.25)'
    ] : [
        'rgba(33, 128, 141, 0.15)',
        'rgba(26, 104, 115, 0.15)',
        'rgba(29, 116, 128, 0.2)',
        'rgba(45, 166, 178, 0.15)',
        'rgba(41, 150, 161, 0.2)'
    ];
    
    // 繪製橫向網格線
    for (let i = 0; i < rows; i++) {
        ctx.beginPath();
        
        for (let j = 0; j < cols; j++) {
            const x = (j - cols / 2) * gridSize;
            const z = (i - rows / 2) * gridSize;
            
            const distance = Math.sqrt(x * x + z * z);
            const wave = Math.sin(distance * 0.01 - time * 1.5) * 25 + 
                        Math.sin(z * 0.02 + time * 0.6) * 15 +
                        Math.cos(x * 0.015 - time * 0.4) * 10;
            
            const mouseDistX = (mouseX - centerX) * 0.1;
            const mouseDistY = (mouseY - centerY) * 0.1;
            
            const perspective = 600;
            const scale = perspective / (perspective + z + wave);
            const projX = centerX + x * scale + mouseDistX * scale;
            const projY = centerY + (wave - z * 0.5) * scale + mouseDistY * scale;
            
            if (j === 0) {
                ctx.moveTo(projX, projY);
            } else {
                ctx.lineTo(projX, projY);
            }
        }
        
        const gradient = ctx.createLinearGradient(0, 0, width, height);
        const colorIndex = Math.floor((time * 10 + i * 5) % 5);
        gradient.addColorStop(0, colors[colorIndex]);
        gradient.addColorStop(1, colors[(colorIndex + 1) % 5]);
        
        ctx.strokeStyle = gradient;
        ctx.lineWidth = 1.5;
        ctx.stroke();
    }
    
    // 繪製縱向網格線
    for (let j = 0; j < cols; j++) {
        ctx.beginPath();
        
        for (let i = 0; i < rows; i++) {
            const x = (j - cols / 2) * gridSize;
            const z = (i - rows / 2) * gridSize;
            
            const distance = Math.sqrt(x * x + z * z);
            const wave = Math.sin(distance * 0.01 - time * 1.5) * 25 + 
                        Math.sin(z * 0.02 + time * 0.6) * 15 +
                        Math.cos(x * 0.015 - time * 0.4) * 10;
            
            const mouseDistX = (mouseX - centerX) * 0.1;
            const mouseDistY = (mouseY - centerY) * 0.1;
            
            const perspective = 600;
            const scale = perspective / (perspective + z + wave);
            const projX = centerX + x * scale + mouseDistX * scale;
            const projY = centerY + (wave - z * 0.5) * scale + mouseDistY * scale;
            
            if (i === 0) {
                ctx.moveTo(projX, projY);
            } else {
                ctx.lineTo(projX, projY);
            }
        }
        
        const gradient = ctx.createLinearGradient(0, 0, width, height);
        const colorIndex = Math.floor((time * 10 + j * 5) % 5);
        gradient.addColorStop(0, colors[colorIndex]);
        gradient.addColorStop(1, colors[(colorIndex + 1) % 5]);
        
        ctx.strokeStyle = gradient;
        ctx.lineWidth = 1.5;
        ctx.stroke();
    }
}

function animate() {
    time += 0.016;
    
    const darkMode = isDarkMode();
    const bgColor = darkMode ? 'rgba(10, 10, 10, 1)' : 'rgba(252, 252, 249, 1)';
    
    ctx.fillStyle = bgColor;
    ctx.fillRect(0, 0, width, height);
    
    drawGrid();
    drawMatrix();
    
    requestAnimationFrame(animate);
}

animate();

創建 Partial 模板
#

/layouts/partials路徑下創建3d-grid-n-digi-rain-bg.html模板並編輯:

<canvas id="3d-grid-n-digi-rain-bg"></canvas> <!-- 創建畫布元素 -->
{{ $js := resources.Get "js/3d-grid-n-digi-rain-bg.js" | resources.Minify | resources.Fingerprint (site.Params.fingerprintAlgorithm | default "sha512") }}
{{ if $js }}<script src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>{{ end }} <!-- 載入 JavaScript --> 

設定網站全局參數配置文件 params.toml
#

編輯/config/_default/params.toml並在文件中加入:

# 動畫背景(可選,不設或設為空則不顯示)
[animationBackground]
  # 是否啟用動畫背景(可選,預設 false;完全不顯示)
  enabled = false
  # 要載入的 partial 名稱(不含 .html),例如 "3d-grid-n-digi-rain-bg",檔案必須放在`layouts/partials/`下
  partial = "3d-grid-n-digi-rain-bg"
  # 顯示頁面:"all" 每頁都顯示|"home" 僅首頁|"sections" 僅在下方 sections 列出的區塊顯示
  show = "all"
  # 僅當 show = "sections" 時有效。列出要顯示背景的 section 目錄名稱,例如 ["posts", "series"]
  sections = ["posts", "series"]
  # 透明度 0.0~1.0(可選,預設 1)
  opacity = 0.85
  # 是否在閱讀模式(非首頁)使用較低的背景透明度(可選,預設 true)
  readingModeOpacityEnabled = true
  # 閱讀模式時的背景透明度 0.0~1.0(僅在 readingModeOpacityEnabled = true 時有效,預設 0.35)
  readingModeOpacity = 0.35

上面的設定能讓你選擇是否使用動畫背景,也能套用你自己製作的其他動畫背景模板,只要注意存放路徑是否正確即可。

你還能選擇哪些頁面要使用動畫背景,並調整透明度。

最後,有些動畫背景可能會影響閱讀,就像本文的 3D 網格加代碼瀑布風格動畫背景,所以我還增加了「閱讀模式」,可以選擇是否開啟,並設定在非主頁(文章頁面等)的透明度。

套用 Partial 模板
#

找到網站主模板檔案並編輯,通常是/layouts/_default/baseof.html。像我使用 Blowfish 主題,就將/themes/blowfish/layouts/_default下的baseof.html複製到/layouts/_default並編輯。

套用到所有頁面
#

如果要套用此動畫背景到所有頁面,在baseof.html中的<body>標籤後加入:

<!doctype html>
<html lang="zh-TW">
<head>
    <!-- 原始 head 內容 ... -->
</head>
<body>
    <!-- 加入這段 -->
    {{ if and $bg (ne $bg.enabled false) $bg.partial }}
      {{ $show := $bg.show | default "all" }}
      {{ $showBg := false }}
      {{ if eq $show "all" }}{{ $showBg = true }}
      {{ else if eq $show "home" }}{{ $showBg = .IsHome }}
      {{ else if eq $show "sections" }}{{ $showBg = in ($bg.sections | default slice) .Section }}{{ end }}
      {{ if $showBg }}
        {{ partial $bg.partial (dict "Page" . "Opacity" ($bg.opacity | default 1)) }}
      {{ end }}
    {{ end }}
    <!-- 原始 body 內容 -->
</body>
</html>
透過郵件回覆
YoZ 柚子
作者
YoZ 柚子
韓國實用音樂系留學生/FOSS Nerd/ISTJ/襯衫、墨鏡愛好者
Hugo Blowfish 主題客製化 - 本文屬於一個選集。
§ 4: 本文

相關文章